commit 79248033512c8babd78ce5c2f884dcf0b22f0344 Author: Janez Troha Date: Fri Sep 4 16:53:52 2026 +0200 chore: Initial commit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9269f8a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Report a reproducible bug in MeshCore SAR App +title: "[Bug]: " +labels: [bug] +assignees: [] +--- + +## Summary +A short description of the problem. + +## Reported context +Where this was observed (build/version, environment, device model, logs, links). + +## Steps to reproduce +1. +2. +3. + +## Expected behavior +What should happen. + +## Actual behavior +What actually happens. + +## Impact +Why this matters (user impact, risk, severity). + +## Acceptance criteria +- [ ] Repro case is fixed. +- [ ] Behavior matches expected result. +- [ ] Regression coverage is added/updated. diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml new file mode 100644 index 0000000..18c283f --- /dev/null +++ b/.github/workflows/build-artifacts.yml @@ -0,0 +1,434 @@ +name: Build And Upload Release Assets + +on: + push: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + pages: write + id-token: write + +env: + FLUTTER_VERSION: "3.41.4" + APP_NAME: meshcore-sar + ANDROID_NDK_VERSION: "27.0.12077973" + +jobs: + build-android: + name: Build Android APK + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Setup Gradle cache + uses: gradle/actions/setup-gradle@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + 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 + with: + path: | + /usr/local/lib/android/sdk/ndk/${{ env.ANDROID_NDK_VERSION }} + /usr/local/lib/android/sdk/licenses + key: ${{ runner.os }}-android-ndk-${{ env.ANDROID_NDK_VERSION }} + + - 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 < 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: 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 Android artifacts + uses: actions/upload-artifact@v4 + with: + name: release-android + 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 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + cmake \ + ninja-build \ + pkg-config \ + libgtk-3-dev \ + liblzma-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + 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 + + - name: Install dependencies + run: flutter pub get + + - name: Build Linux release + run: flutter build linux --release + + - name: Package Linux artifact + run: | + RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}" + TAG="${RAW_TAG//\//-}" + tar -C build/linux/x64/release -czf "${APP_NAME}-${TAG}-linux.tar.gz" bundle + + - name: Upload Linux artifact + uses: actions/upload-artifact@v4 + with: + name: release-linux + path: "${{ env.APP_NAME }}-*-linux.tar.gz" + if-no-files-found: error + + build-windows: + name: Build Windows App + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + 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 + run: flutter config --enable-windows-desktop + + - name: Install dependencies + shell: pwsh + run: flutter pub get + + - name: Build Windows release + shell: pwsh + run: flutter build windows --release + + - name: Package Windows artifact + shell: pwsh + run: | + $RawTag = "${{ github.event.release.tag_name || github.ref_name }}" + $Tag = $RawTag -replace '/', '-' + $Output = "${{ env.APP_NAME }}-$Tag-windows.zip" + Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath $Output + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: release-windows + path: "${{ env.APP_NAME }}-*-windows.zip" + if-no-files-found: error + + build-macos: + name: Build macOS DMG + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + 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 + + - 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 + + - name: Create DMG + run: | + RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}" + TAG="${RAW_TAG//\//-}" + APP_PATH=$(find build/macos/Build/Products/Release -maxdepth 1 -name "*.app" -print -quit) + if [ -z "$APP_PATH" ]; then + echo "No .app bundle found in build output" + exit 1 + fi + hdiutil create -volname "MeshCore SAR" -srcfolder "$APP_PATH" -ov -format UDZO "${APP_NAME}-${TAG}-macos.dmg" + + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: release-macos + path: "${{ env.APP_NAME }}-*-macos.dmg" + if-no-files-found: error + + build-ios: + name: Build iOS Archive (Manual Signing) + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + 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 + + - name: Build iOS app (no codesign) + run: flutter build ios --release --no-codesign + + - name: Package iOS unsigned artifacts + run: | + RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}" + TAG="${RAW_TAG//\//-}" + APP_PATH="build/ios/iphoneos/Runner.app" + IPA_NAME="${APP_NAME}-${TAG}-ios-unsigned.ipa" + RUNNER_ZIP_NAME="${APP_NAME}-${TAG}-ios-runner-app.zip" + if [ ! -d "$APP_PATH" ]; then + echo "Runner.app not found at $APP_PATH" + exit 1 + fi + + mkdir -p ios_payload/Payload + cp -R "$APP_PATH" ios_payload/Payload/ + ( + cd ios_payload + zip -qry "../${IPA_NAME}" Payload + ) + rm -rf ios_payload + + zip -qry "${RUNNER_ZIP_NAME}" "$APP_PATH" + + echo "IOS_IPA=${IPA_NAME}" >> "$GITHUB_ENV" + echo "IOS_RUNNER_ZIP=${RUNNER_ZIP_NAME}" >> "$GITHUB_ENV" + ls -lah "${IPA_NAME}" "${RUNNER_ZIP_NAME}" + + - name: Upload iOS artifact + uses: actions/upload-artifact@v4 + with: + name: release-ios + path: | + ${{ env.IOS_IPA }} + ${{ env.IOS_RUNNER_ZIP }} + if-no-files-found: error + + build-web: + name: Build Web App + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + 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 + + - name: Install dependencies + run: flutter pub get + + - name: Build web release + run: flutter build web --wasm --release --base-href /meshcore-sar/ + + - name: Upload pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: build/web + + deploy-pages: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + needs: build-web + if: github.ref == 'refs/heads/main' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + upload-release-assets: + name: Upload Assets To Release + if: github.event_name == 'release' + runs-on: ubuntu-latest + needs: + - build-android + - build-linux + - build-windows + - build-macos + - build-ios + + steps: + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + pattern: release-* + path: dist + merge-multiple: true + + - name: Upload assets to published release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name || github.ref_name }} + body: | + Release assets are published for all supported build targets: + - Android (`.apk`) + - Google Play (`.aab`) + - Linux (`.tar.gz`) + - macOS (`.dmg`) + - Windows (`.zip`) + - iOS unsigned (`-ios-unsigned.ipa`, `-ios-runner-app.zip`) + + Windows status: currently unusable. The BLE stack on Windows is broken, so BLE features do not work. + + iOS manual signing and install: + 1. Download `*-ios-unsigned.ipa` (or `*-ios-runner-app.zip`) from this release. + 2. Re-sign it with your Apple Development/Distribution certificate and provisioning profile. + 3. If you used `*-ios-runner-app.zip`, create a signed `.ipa` from `Runner.app` during signing. + 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 + dist/*-ios-unsigned.ipa + dist/*-ios-runner-app.zip + fail_on_unmatched_files: true diff --git a/.github/workflows/build-multiplatform.yml b/.github/workflows/build-multiplatform.yml new file mode 100644 index 0000000..892a483 --- /dev/null +++ b/.github/workflows/build-multiplatform.yml @@ -0,0 +1,41 @@ +name: Flutter Analyze + +# This workflow runs flutter analyze to check for code issues. + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + workflow_dispatch: + +env: + FLUTTER_VERSION: '3.41.4' + +jobs: + analyze: + name: Flutter Analyze + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + 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 + + - name: Generate localizations + run: flutter gen-l10n + + - name: Run flutter analyze + run: flutter analyze diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b266df0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,99 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +pubspec_overrides.yaml +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Android signing +android/key.properties +android/app/*.keystore +android/app/*.jks +*.jks +*.keystore +*.zip +*.ipa +ios/Runner.app.dSYM.zip +ios/Runner.ipa +ios/build/ios/XCBuildData/PIFCache + +# iOS signing & certificates +*.p8 +*.p12 +*.cer +*.mobileprovision +*.certSigningRequest + +# Firebase / Google services (contain API keys) +google-services.json +GoogleService-Info.plist + +# Fastlane credentials — store credentials in CI env vars, not files +**/fastlane/Appfile +**/fastlane/.env +**/fastlane/.env.* +fastlane/README.md +ios/.bundle/ +ios/vendor/ + +# Play Store release assets & scripts +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 + +# Dart code coverage +coverage/ +lcov.info diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..70a7d44 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "9f455d2486bcb28cad87b062475f42edc959f636" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: android + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: ios + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: linux + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: macos + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: web + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + - platform: windows + create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/AppIcon.icon/Assets/icon.png b/AppIcon.icon/Assets/icon.png new file mode 100644 index 0000000..6753d4a Binary files /dev/null and b/AppIcon.icon/Assets/icon.png differ diff --git a/AppIcon.icon/icon.json b/AppIcon.icon/icon.json new file mode 100644 index 0000000..387ea08 --- /dev/null +++ b/AppIcon.icon/icon.json @@ -0,0 +1,38 @@ +{ + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "blend-mode" : "soft-light", + "glass" : true, + "image-name" : "icon.png", + "name" : "icon", + "position" : { + "scale" : 1.4, + "translation-in-points" : [ + 2.625, + 5.3203125 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..43b4cb1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## 2026-03-15 to 2026-03-22 + +- Device connectivity and routing: added Android USB serial companion support, serial compass support, GPS module controls, per-contact path size selection, and favourites support via firmware flags. +- Location and map intelligence: added RSSI-based repeater trilateration and contact location estimates, isolated MET history, matched the official advert import/discovery flow, and refreshed map presentation by removing contact trails. +- Telemetry and device info: surfaced self telemetry and sensor defaults, opened device info from signal and battery taps, improved signal chip behavior, fixed duplicate telemetry delivery, and restored telemetry refresh behavior. +- Contacts and messaging: added meshcore:// contact sharing and repeater neighbour views, supported MeshCore message links, fixed DM retry behavior, corrected room poster naming, and compacted the recipient selector. +- UI polish and platform upkeep: improved device settings layout, tightened sensor and contact presentation, expanded localization coverage, and refreshed platform dependencies and build numbers. + +## Key PRs + +- [#26](https://github.com/dz0ny/meshcore-sar/pull/26) Merge pull request #26 from `dz0ny/feat/sensor-telemetry-preview` + +## 2026-03-08 to 2026-03-14 + +- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides. +- Messaging and channels: fixed channel sync, deduplication, overwrite and deletion issues, cleared message input after send, and added delayed-message warning tracking. +- Voice and transport: improved voice playback quality, improved scan and advert parsing, and kept route path data visible in logs. +- Maps and field operations: enabled iOS background GPS updates, added GPX trail import/export, introduced a fullscreen repeater map, and added live mesh traffic views. +- Localization and UI polish: added multilingual localization coverage, updated locale strings, fixed onboarding layout issues, and refined channel and contacts presentation. + +## Key PRs + +- [#16](https://github.com/dz0ny/meshcore-sar/pull/16) Merge pull request #16 from `MGJ520/main` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3d4d8b4 --- /dev/null +++ b/Makefile @@ -0,0 +1,144 @@ +# MeshCore SAR Makefile +# Version format: YYYY.MMDD.N+build (Flutter-compatible semver with date) +# Example: 2025.1205.1+1 + +# Configuration +APP_NAME := meshcore-sar +PUBSPEC := pubspec.yaml +BUILD_DIR := build/app/outputs/flutter-apk + +# Get current date parts +YEAR := $(shell date +%Y) +MMDD := $(shell date +%m%d) + +# Get current version info from pubspec.yaml +CURRENT_VERSION := $(shell grep '^version:' $(PUBSPEC) | sed 's/version: //') +CURRENT_BUILD_NUMBER := $(shell echo $(CURRENT_VERSION) | cut -d'+' -f2) + +# Extract current MMDD from version (middle part) +CURRENT_MMDD := $(shell echo $(CURRENT_VERSION) | cut -d'.' -f2) + +# Calculate new build number +# IMPORTANT: Build number (version code) must ALWAYS increment for Android updates +# Never reset to 1, even on date changes +NEW_BUILD_NUMBER := $(shell echo $$(($(CURRENT_BUILD_NUMBER) + 1))) + +# Calculate daily sequence number (resets each day for version name readability) +DAILY_BUILD := $(shell \ + if [ "$(CURRENT_MMDD)" = "$(MMDD)" ]; then \ + echo $(CURRENT_VERSION) | cut -d'.' -f3 | cut -d'+' -f1 | awk '{print $$1 + 1}'; \ + else \ + echo 1; \ + fi) + +# Version format: YYYY.MMDD.DAILY+BUILD +# DAILY resets each day (for readability), BUILD always increments (for Android) +NEW_VERSION := $(YEAR).$(MMDD).$(DAILY_BUILD)+$(NEW_BUILD_NUMBER) +NEW_BUILD_NAME := $(shell echo $(NEW_VERSION) | cut -d'+' -f1) + +.PHONY: help version bump make-bump sync-ios-version build release release-android release-ios clean deps analyze test icon bundle bundle-no-bump + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +version: ## Show current and next version + @echo "Current version: $(CURRENT_VERSION)" + @echo "Current build: $(CURRENT_BUILD_NUMBER)" + @echo "---" + @echo "Next version: $(NEW_VERSION)" + +bump: ## Bump version in pubspec.yaml + @echo "Bumping version to $(NEW_VERSION)..." + @sed -i '' 's/^version: .*/version: $(NEW_VERSION)/' $(PUBSPEC) + @$(MAKE) sync-ios-version BUILD_NAME=$(NEW_BUILD_NAME) BUILD_NUMBER=$(NEW_BUILD_NUMBER) + @echo "Version bumped to $(NEW_VERSION)" + +make-bump: bump ## Alias for bump + +sync-ios-version: ## Sync iOS FLUTTER_BUILD_NAME/NUMBER from pubspec or provided BUILD_NAME/BUILD_NUMBER + @$(eval IOS_VERSION := $(shell grep '^version:' $(PUBSPEC) | sed 's/version: //')) + @$(eval IOS_BUILD_NAME := $(if $(BUILD_NAME),$(BUILD_NAME),$(shell echo $(IOS_VERSION) | cut -d'+' -f1))) + @$(eval IOS_BUILD_NUMBER := $(if $(BUILD_NUMBER),$(BUILD_NUMBER),$(shell echo $(IOS_VERSION) | cut -d'+' -f2))) + @echo "Syncing iOS version to $(IOS_BUILD_NAME)+$(IOS_BUILD_NUMBER)..." + @flutter build ios --config-only --build-name "$(IOS_BUILD_NAME)" --build-number "$(IOS_BUILD_NUMBER)" > /dev/null + @echo "iOS version synced" + +deps: ## Install dependencies + flutter pub get + +analyze: ## Run Flutter analyze + flutter analyze + +test: ## Run tests + flutter test + +icon: ## Generate app icons from icon.png + dart run flutter_launcher_icons + +build: bump ## Build release APK (auto-bumps version) + @echo "Building release APK..." + flutter build apk --release + @echo "APK built: $(BUILD_DIR)/app-release.apk" + @ls -lh $(BUILD_DIR)/app-release.apk + +build-no-bump: ## Build release APK without bumping version + flutter build apk --release + @ls -lh $(BUILD_DIR)/app-release.apk + +bundle: bump ## Build release App Bundle (auto-bumps version) + @echo "Building release App Bundle..." + flutter build appbundle --release + @echo "App Bundle built: build/app/outputs/bundle/release/app-release.aab" + @ls -lh build/app/outputs/bundle/release/app-release.aab + +bundle-no-bump: ## Build release App Bundle without bumping version + flutter build appbundle --release + @ls -lh build/app/outputs/bundle/release/app-release.aab + +release: build release-ios ## Build APK, create GitHub release, and upload iOS to TestFlight + $(eval VERSION := $(shell grep '^version:' $(PUBSPEC) | sed 's/version: //' | cut -d'+' -f1)) + @echo "Creating GitHub release v$(VERSION)..." + @if ! command -v gh &> /dev/null; then \ + echo "Error: GitHub CLI (gh) not installed. Install with: brew install gh"; \ + exit 1; \ + fi + @cp $(BUILD_DIR)/app-release.apk $(BUILD_DIR)/$(APP_NAME)-$(VERSION).apk + gh release create "v$(VERSION)" \ + "$(BUILD_DIR)/$(APP_NAME)-$(VERSION).apk#MeshCore SAR $(VERSION) APK" \ + --title "MeshCore SAR v$(VERSION)" \ + --notes "Release $(VERSION)" \ + --latest + @echo "Release v$(VERSION) created!" + +release-android: build ## Build APK and create GitHub release (Android only) + $(eval VERSION := $(shell grep '^version:' $(PUBSPEC) | sed 's/version: //' | cut -d'+' -f1)) + @echo "Creating GitHub release v$(VERSION)..." + @if ! command -v gh &> /dev/null; then \ + echo "Error: GitHub CLI (gh) not installed. Install with: brew install gh"; \ + exit 1; \ + fi + @cp $(BUILD_DIR)/app-release.apk $(BUILD_DIR)/$(APP_NAME)-$(VERSION).apk + gh release create "v$(VERSION)" \ + "$(BUILD_DIR)/$(APP_NAME)-$(VERSION).apk#MeshCore SAR $(VERSION) APK" \ + --title "MeshCore SAR v$(VERSION)" \ + --notes "Release $(VERSION)" \ + --latest + @echo "Release v$(VERSION) created!" + +release-ios: ## Build iOS and upload to TestFlight + @echo "Building iOS and uploading to TestFlight..." + @$(MAKE) sync-ios-version + cd ios && fastlane release + @echo "iOS release uploaded!" + +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 + flutter build ios --release --no-codesign diff --git a/README.md b/README.md new file mode 100644 index 0000000..df769eb --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +

+ MeshCore SAR icon +

+ +

MeshCore SAR

+ +

+ Field-ready Search and Rescue coordination over MeshCore + BLE +

+ +

+ Platform + Mode + Use case +

+ +MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app. +It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication. +`iOS TestFlight:` https://testflight.apple.com/join/tngpPF12 + +## Highlights + +- Rapid mesh chat for both 1:1 and group coordination +- On-demand voice (Codec2) and image (AVIF) transfer tuned for low-bandwidth links +- Offline-first mapping with tactical overlays and SAR incident markers +- Live team location, movement trails, and shareable tactical drawings + +## Demo Video + +[MeshCore SAR demo preview](https://youtu.be/rLsKeLJBpFg) + +## Screenshots + +

+ Map view with live team context + Compass and navigation view + Contacts and repeaters list + Messages with voice and image cards + Quick actions sheet for SAR, voice, and image +

+ +## Feature Overview + +| Area | What you get | +|---|---| +| Messaging | Direct and group chat over mesh, with contact/room awareness from live telemetry | +| Voice | Push-to-talk voice clips (Codec2), fetched on demand when play is pressed, auto-play on completion | +| Images | Camera/gallery image sending (AVIF), auto-compression, tap-to-load receiving, full-screen viewer | +| Maps | Street/topo/satellite/terrain layers, offline tile downloads, optional MBTiles import | +| SAR Operations | Team markers with freshness indicators, SAR markers for incidents and staging points | +| Tracking | Continuous GPS updates, personal trails, distance/duration trail stats | +| Trail Interop | GPX export/import for trail sharing and reuse | +| Tactical Drawing | Line/rectangle drawing, distance measurement, drawing sharing to channel/room | + +## Voice, Image, and Maps + +### Voice +- Built for short, urgent field communication +- On-demand playback fetch reduces unnecessary mesh traffic +- Ideal when typing is impractical during active operations + +### Images +- Pre-send optimization for constrained links +- Tap-to-load receiving keeps channels lightweight +- Full-screen view supports rapid field verification + +### Maps +- Works in both online and offline workflows +- SAR context with team markers, incident markers, and orientation tools +- Combines location, messaging, and tactical layers in one place + +## Built For + +- Search and Rescue field teams +- Incident command and coordination roles +- Operators working in weak/no cellular coverage + +## Connecting Your Radio (BLE) + +The app pairs with your MeshCore radio over Bluetooth Low Energy. + +### Windows + +On Windows 11 (tested on 25H2), pair the radio through the system Bluetooth menu **before** connecting in the app: + +1. Open **Settings → Bluetooth & devices → Add device → Bluetooth**. +2. Select your MeshCore radio and complete pairing, entering the **PIN** when prompted. +3. Launch MeshCore SAR and connect — the radio now appears as a paired device. + +If the radio does not show up or fails to connect in-app, remove it from the Windows Bluetooth menu and repeat the pairing step. + +### iOS / Android + +Connect directly from within the app — no separate system pairing step is required. + +## Permissions (App Use) + +- Bluetooth: mesh device communication +- Location: team tracking and map position updates +- Microphone: voice clip recording +- Camera / Photos: image messaging diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/Gemfile b/android/Gemfile new file mode 100644 index 0000000..859faf2 --- /dev/null +++ b/android/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "fastlane" +gem "ostruct" diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..61b52b4 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,99 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +// Load keystore properties from key.properties file +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +fun resolveSigningValue(propertyKey: String, envKey: String): String? { + val envValue = System.getenv(envKey)?.takeIf { it.isNotBlank() } + if (envValue != null) { + return envValue + } + return (keystoreProperties[propertyKey] as? String)?.takeIf { it.isNotBlank() } +} + +val releaseKeyAlias = resolveSigningValue("keyAlias", "ANDROID_KEY_ALIAS") +val releaseKeyPassword = resolveSigningValue("keyPassword", "ANDROID_KEY_PASSWORD") +val releaseStorePassword = resolveSigningValue("storePassword", "ANDROID_STORE_PASSWORD") +val releaseStoreFilePath = resolveSigningValue("storeFile", "ANDROID_STORE_FILE") +val hasReleaseSigning = + releaseKeyAlias != null && + releaseKeyPassword != null && + releaseStorePassword != null && + releaseStoreFilePath != null + +android { + namespace = "com.meshcore.sar.meshcore_sar_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + // Enable BuildConfig generation + buildFeatures { + buildConfig = true + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + applicationId = "com.meshcore.sar.meshcore_sar_app" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + + // Inject commit hash from environment variable (set by GitHub Actions) + // Falls back to "dev" for local development builds + val commitHash = System.getenv("COMMIT_HASH") ?: "dev" + buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"") + } + + signingConfigs { + if (hasReleaseSigning) { + create("release") { + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + storeFile = file(releaseStoreFilePath!!) + storePassword = releaseStorePassword + } + } + } + + buildTypes { + release { + signingConfig = + if (hasReleaseSigning) { + signingConfigs.getByName("release") + } else { + // Keep CI release builds working even when private keystore isn't provided. + signingConfigs.getByName("debug") + } + } + } +} + +flutter { + source = "../.." +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..be8136f --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/BuildInfoChannel.kt b/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/BuildInfoChannel.kt new file mode 100644 index 0000000..7ae50aa --- /dev/null +++ b/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/BuildInfoChannel.kt @@ -0,0 +1,35 @@ +package com.meshcore.sar.meshcore_sar_app + +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** + * Platform channel for exposing build information to Flutter + * Provides access to BuildConfig values that are injected at build time + */ +class BuildInfoChannel : MethodCallHandler { + companion object { + const val CHANNEL_NAME = "com.meshcore.sar/build_info" + const val METHOD_GET_COMMIT_HASH = "getCommitHash" + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + METHOD_GET_COMMIT_HASH -> { + try { + // Get commit hash from BuildConfig + // This value is injected by gradle at build time + val commitHash = BuildConfig.COMMIT_HASH + result.success(commitHash) + } catch (e: Exception) { + result.error("BUILD_INFO_ERROR", "Failed to get commit hash: ${e.message}", null) + } + } + else -> { + result.notImplemented() + } + } + } +} diff --git a/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/MainActivity.kt new file mode 100644 index 0000000..af5ce27 --- /dev/null +++ b/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/MainActivity.kt @@ -0,0 +1,17 @@ +package com.meshcore.sar.meshcore_sar_app + +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Register BuildInfoChannel to expose build information to Flutter + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + BuildInfoChannel.CHANNEL_NAME + ).setMethodCallHandler(BuildInfoChannel()) + } +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 0000000..fd20a8e Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000..8c6e630 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000..ff9d71c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000..41d83ae Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000..07c3067 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..d662b76 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..8df6c93 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..b4ef31d --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,42 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + // flutter_avif_android 3.1.0 (latest) ships duplicate Java and Kotlin + // copies of FlutterAvifPlugin. Kotlin 2.2+ rejects the redeclaration. The + // plugin's Java source dir contains only this redundant copy, so empty it + // and let the identical Kotlin implementation compile. kotlinc reads the + // module's Java source roots for joint compilation, so clearing srcDirs + // (rather than an exclude filter, which AGP doesn't forward to kotlinc) is + // what actually removes the duplicate from its view. Registered before + // evaluationDependsOn so it also covers :app, which that call evaluates + // eagerly. + afterEvaluate { + if (project.name == "flutter_avif_android") { + extensions.findByName("android")?.let { ext -> + (ext as com.android.build.gradle.BaseExtension) + .sourceSets.getByName("main").java + .setSrcDirs(emptyList()) + } + } + } + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile new file mode 100644 index 0000000..f18cbc5 --- /dev/null +++ b/android/fastlane/Fastfile @@ -0,0 +1,87 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +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 :direct_apk do + sh(direct_apk_build_command) + + UI.success("APK built successfully at: #{direct_apk_path}") + end + + 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: 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 diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..1607921 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,13 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false +# Kotlin 2.2+ fails the build when a module's Java and Kotlin compile tasks +# target different JVM versions. Several Flutter plugins still pin mismatched +# targets (e.g. Java 1.8 vs Kotlin 1.8, or Java 11 vs Kotlin 17). On Android +# every class is dexed by D8/R8, so the class-file version mismatch is benign; +# downgrade the strict check to a warning so third-party plugins build. +kotlin.jvm.target.validation.mode=warning diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..2bc8e05 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,4 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - provider: true \ No newline at end of file diff --git a/docs/google-play-release.md b/docs/google-play-release.md new file mode 100644 index 0000000..03e73b2 --- /dev/null +++ b/docs/google-play-release.md @@ -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--android.apk` +- Release AAB asset: `meshcore-sar--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. diff --git a/docs/image-mode-technical.md b/docs/image-mode-technical.md new file mode 100644 index 0000000..d5d6743 --- /dev/null +++ b/docs/image-mode-technical.md @@ -0,0 +1,324 @@ +# Image Mode Technical Design + +## 1. Overview + +Image mode mirrors the voice on-demand architecture exactly, including +swarm-assisted recovery for stalled partial transfers: + +- **Control plane (text messages):** + - `IE4:` image envelope announces image availability in chat. +- **Control plane (raw binary request):** + - Binary image fetch request (same raw route as image fragments). +- **Data plane (raw binary packets):** + - `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`. + +Images are never broadcast in full to channels. Chat carries only metadata; +pixels are fetched on demand when the user taps the image bubble. + +Shared swarm fallback is documented in +[Swarm Mode Technical Design](./swarm-mode-technical.md). + +## 2. Key Modules + +- `lib/utils/image_message_parser.dart` + - `ImagePacket` (binary fragment format) + - `ImageEnvelope` (`IE4`) + - `ImageFetchRequest` (binary) + - `fragmentImage()` — split compressed bytes into packets + - `reassembleImage()` — join received fragments into bytes +- `lib/screens/messages_tab.dart` + - Pick image from gallery or camera, compress with user settings, cache, send envelope +- `lib/providers/image_provider.dart` + - Reassembly sessions, outgoing cache, deferred serving + - Outgoing sessions also registered as complete incoming sessions for immediate local display + - Received partial sessions can be re-served during swarm recovery +- `lib/providers/app_provider.dart` + - Incoming routing for `IE4`, binary image fetch requests, binary `0x49` packets, and raw swarm control payloads +- `lib/widgets/messages/image_message_bubble.dart` + - Square cover thumbnail (up to 256 px); tap-to-load for received images; + progress ring during fetch; full-screen `InteractiveViewer` on tap +- `lib/services/image_codec_service.dart` + - `ImageCodecService.compress()` — aspect-ratio-preserving resize + optional + grayscale + AVIF encode via `flutter_avif` +- `lib/services/image_preferences.dart` + - `ImagePreferences` — persists max size, compression level, grayscale toggle + +## 3. Wire Formats + +### 3.1 Image Envelope (`IE4`) + +Prefix: `IE4:` + colon-delimited compact payload (base36 numeric fields) + +Fields: + +| Field | Type | Description | +|--------------|--------|------------------------------------------------| +| `sid` | string | base36 token for 32-bit session ID | +| `fmt` | base36 | `ImageFormat.id` (0 = AVIF, 1 = JPEG) | +| `total` | base36 | Fragment count (1..255) | +| `w` | base36 | Actual image width after compression (pixels) | +| `h` | base36 | Actual image height after compression (pixels) | +| `bytes` | base36 | Total compressed size in bytes | +Compact format: + +```text +IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes} +``` + +Example (256×171 landscape image, 14 fragments): + +```text +IE4:a:0:e:74:4r:1mc +``` + +Note: `sid` is base36 on wire and expands to 8-hex internally. +`w` and `h` reflect the actual post-compression dimensions, which preserve +the source aspect ratio (contain within the configured max size). + +### 3.2 Image Fetch Request (`IR4` + binary) + +Text format: + +```text +IR4:{sid}:{want}:{requesterKey6} +``` + +Binary payload format: + +```text +[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...] +``` + +| Field | Value | +|------------------|--------------------------| +| `flags` | bit0=1 => request missing indices, else all | +| `requesterKey6` | 6-byte requester key prefix | +### 3.3 Raw Image Packet (data plane) + +Binary payload structure: + +- Byte 0: magic `0x49` (`'I'`) +- Bytes 1..4: session ID (4 bytes) +- Byte 5: fragment index (0-based) +- Bytes 6..N: image data (max 152 bytes per fragment) + +Header is 6 bytes. Image format and total fragment count come from the `IE4` envelope. + +## 4. Compression Pipeline + +`ImageCodecService.compress(rawBytes, {maxDimension, compression, grayscale})`: + +1. **Probe** — decode source image at original resolution to read `srcW × srcH`. +2. **Contain** — compute `dstW × dstH` that fits within `maxDimension × maxDimension` + while preserving aspect ratio. Images smaller than the cap are not upscaled. +3. **Decode** — `dart:ui.instantiateImageCodec` with `targetWidth: dstW, targetHeight: dstH`. +4. **RGBA export** — `image.toByteData(format: rawRgba)`. +5. **Grayscale** *(optional, default on)* — luminance `0.299R + 0.587G + 0.114B` + applied in-place; if disabled, colour pixels are passed through unchanged. +6. **PNG re-encode** — `ui.ImageDescriptor.raw` → `toByteData(format: png)`. + Required because `encodeAvif()` takes an encoded image, not raw RGBA. +7. **AVIF encode** — `flutter_avif.encodeAvif(pngBytes, maxQuantizer, minQuantizer, speed: 8)`. + `compression` (10–90) maps to libavif CQ scale: + `maxQ = round(compression/100 × 63)`, `minQ = round(maxQ × 0.65)`. +8. **Fragment** — `fragmentImage()` at 152 bytes/packet (≤255 packets). + +Returns `({Uint8List bytes, int width, int height})` with the actual compressed dimensions. + +### User-configurable settings (`ImagePreferences`) + +| Setting | Key | Default | Range / Options | +|--------------|--------------------|---------|-----------------| +| Max size | `image_max_size` | 256 | 64 / 128 / 256 | +| Compression | `image_quality` | 90 | 10–90 | +| Grayscale | `image_grayscale` | true | true / false | + +### Expected sizes (grayscale AVIF, compression 90) + +| Max size | Typical size | Fragments | +|----------|--------------|-----------| +| 64×64 | 100–400 B | 1–3 | +| 128×128 | 300–900 B | 2–6 | +| 256×256 | 700–3000 B | 5–20 | + +Non-square images produce fewer fragments than the square equivalent because +only the shorter axis is padded — no cropping occurs. + +## 5. Outgoing Flow (Send) + +1. User picks image from gallery or camera (`image_picker`). +2. `ImagePreferences` is read: max size, compression, grayscale. +3. `ImageCodecService.compress()` returns `(bytes, width, height)`. +4. `fragmentImage()` splits bytes into `ImagePacket` list (≤255 fragments). +5. `ImageProvider.cacheOutgoingSession()` stores fragments in both the outgoing + cache (for serving to peers) and the incoming session map (so the local bubble + renders the image immediately without a fetch round-trip). +6. `ImageEnvelope` built with actual `width`/`height` from step 3. +7. Envelope sent via normal message path: + - Channel: `sendChannelMessage` + - Direct: `sendTextMessage` +8. Local placeholder message added (`IE4:` text, `deliveryStatus.sending`). + +## 6. Incoming Flow (Receive) + +### 6.1 `IE4` envelope received + +`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to +chat. The bubble shows a grey square placeholder with a download icon. + +### 6.2 Binary image fetch request received + +`AppProvider` treats it as control-plane only (not added to chat): + +- Resolves requester contact. +- Calls `imageProvider.serveSessionTo()` which streams all cached fragments. + +### 6.3 Swarm control messages received + +`AppProvider` also handles shared raw swarm discovery payloads: + +- binary swarm requests advertise which image fragments are still missing +- binary swarm availability responses advertise which fragments another peer can relay +- swarm payloads arrive via `pushRawData` and are intercepted instead of being added to chat + +Shared discovery and responder semantics are documented in +[Swarm Mode Technical Design](./swarm-mode-technical.md). + +### 6.4 Raw packet received (`pushRawData`, magic `0x49`) + +`AppProvider.onRawDataReceived` parses `ImagePacket` binary and calls +`imageProvider.addFragment()`. When the session becomes complete, the bubble +automatically rebuilds with the full image. + +## 7. Outgoing Cache Details + +`ImageProvider` outgoing cache: + +- key: `sessionId` +- value: encoded fragment list + cached envelope + timestamp +- TTL: 15 minutes +- eviction: lazy on access + +When `cacheOutgoingSession()` is called it also writes all fragments into +`_sessions[sessionId]`, so the sender sees the image immediately in the bubble +(no tap-to-load required for own messages). + +If a peer later receives only part of an image session, those received fragments +can also be served onward to another requester during swarm recovery. + +## 8. Display + +`ImageMessageBubble` (max width 256 px): + +- **Complete session**: `AspectRatio(1.0)` → `AvifImage.memory(fit: cover)` + square thumbnail; tap → full-screen `InteractiveViewer` with fade transition. +- **Incomplete/missing**: grey square placeholder with download icon; + tap → sends binary fetch request, with raw swarm fallback if the original + sender path stalls. +- **Loading**: circular progress indicator showing `received/total` count. +- **Error**: broken-image icon. + +Status line below thumbnail: + +- Sender: `🖼️ {w}×{h} AVIF · {n} seg` +- Receiver (complete): `🖼️ {w}×{h} AVIF` +- Receiver (pending): `🖼️ Tap to load · {w}×{h}` +- Loading: `📥 Loading… {received}/{total}` + +## 9. Transmit Time Estimate (UI) + +Image bubbles and Message Technical Details show an **estimated transmit time** (`~... tx`). + +The estimate is airtime-based (LoRa packet model), not just compressed image size: + +- Source inputs: + - `total` fragments and `bytes` from `IE4` envelope + - all numeric envelope values are decoded from base36 + - `pathLen` from message metadata + - current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr` +- Per-fragment payload model: + - `meshHeader(2)` + `pathLen` + `imageHeader(6)` + `fragmentBytes` +- LoRa airtime: + - standard symbol-time formula (preamble + payload symbols) +- Mesh pacing/hops: + - multiplied by `(1 + airtimeBudgetFactor)` where default factor is `1.0` + - multiplied by hop count `(pathLen + 1)` +- Total estimate: + - sum over all fragments + +BW handling: + +- If `radioBw` is index `0..9`, app maps it to Hz (`7.8k` .. `500k`) +- If `radioBw > 1000`, it is treated as Hz directly + +Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`, `CR5`. + +## 10. Persistence + +`ImageProvider` stores sessions in `SharedPreferences` under key +`stored_image_sessions_v1`: + +- Incoming: fragment list serialized as base64 binary packets. +- Outgoing: fragment list + envelope text + `cachedAt` timestamp. +- Expired outgoing sessions (> 15 min) are not restored on startup. + +## 11. Operational Constraints + +- No firmware changes required (reuses `cmdSendRawData` / `pushRawData`). +- On-demand fetch prefers the original sender, but a partial image can also be + completed from alternate peers that already hold matching fragments. +- Raw return path requires a valid direct route to requester. +- Available on iOS and Android (`image_picker` + `flutter_avif`). +- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as image + fetch and fragment delivery. + +### 11.1 Raw Binary Routing Semantics + +- Image fragment payloads use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`). +- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`). +- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`). +- This flow is direct-route only, not flood/broadcast: + - it is sent to one destination path; + - only nodes on that path relay it; + - it is **not** received by everyone in the mesh. + +## 12. Swarm Fallback Sequence + +```mermaid +sequenceDiagram + participant A as Original Sender + participant P as Peer With Fragments + participant N as Reachable Peers + participant B as Receiver App + + A->>M: Send IE4 envelope + M->>B: Deliver IE4 + B->>A: Direct binary image fetch request + A->>B: Stream raw ImagePacket fragments (partial) + Note over A,B: Sender path stops responding + B->>N: Raw swarm requests with missing image fragment indices + P->>B: Raw swarm availability response + B->>P: Direct binary fetch request for missing subset + P->>B: Stream remaining raw ImagePacket fragments + B->>B: Reassemble completed image +``` + +## 13. High-Level Sequence + +```mermaid +sequenceDiagram + participant A as Sender App + participant M as Mesh Chat + participant B as Receiver App + + A->>A: Pick image (gallery or camera) + A->>A: Compress: contain resize → grayscale → PNG → AVIF + A->>A: Fragment into ≤152B packets + A->>A: Cache outgoing + populate local session (immediate display) + A->>M: Send IE4 envelope (actual w×h, fragment count) + M->>B: Deliver IE4 + B->>B: Render grey placeholder bubble + B->>A: Tap → send binary fetch request + A->>B: Stream binary ImagePackets + B->>B: Reassemble fragments + B->>B: Display AVIF image (cover thumbnail) +``` diff --git a/docs/swarm-mode-technical.md b/docs/swarm-mode-technical.md new file mode 100644 index 0000000..d945f6f --- /dev/null +++ b/docs/swarm-mode-technical.md @@ -0,0 +1,148 @@ +# Swarm Mode Technical Design + +## 1. Overview + +Swarm mode is a shared media-recovery transport used by both voice and image +sessions when the original sender path stops responding after some fragments +have already propagated through the mesh. + +It adds a lightweight **swarm discovery plane** on top of the existing +**direct raw-data transfer plane**: + +- **Discovery plane (raw custom control payloads):** + - `MediaSwarmRequest` + - `MediaSwarmAvailability` +- **Transfer plane (direct raw binary):** + - existing `VoiceFetchRequest` / `ImageFetchRequest` + - existing `VoicePacket` / `ImagePacket` + +Swarm mode does not broadcast media payloads. It fans out raw control requests +to reachable peers, collects raw availability responses, and then fetches media +from the best responder. + +## 2. Problem It Solves + +Without swarm mode, media fetch is limited to the original sender's direct raw +path. If that sender goes offline, moves, or stops responding, a receiver can +be left with a partial session even when other peers already hold useful +fragments. + +Swarm mode lets the receiver discover alternate peers and fetch the missing +subset directly from them. + +## 3. Control Messages + +### 3.1 Media Swarm Request (binary) + +```text +[magic=0x6d][kind=0x01][mediaType:1B][sessionId:4B][requesterKey6:6B][missingCount:1B][missingIndices...] +``` + +Fields: + +- `mediaType` — `voice` or `image` +- `sessionId` — 8 hex chars +- `requesterKey6` — 12 hex chars identifying the requesting device +- `missingCount` — `0` means the requester needs all fragments +- `missingIndices` — exact missing fragment indices when `missingCount > 0` + +Example: + +```text +6d 01 02 de ad be ef aa bb cc dd ee ff 03 00 02 09 +``` + +### 3.2 Media Swarm Availability (binary) + +```text +[magic=0x6d][kind=0x02][mediaType:1B][sessionId:4B][requesterKey6:6B][responderKey6:6B][availableCount:1B][availableIndices...] +``` + +Fields: + +- `mediaType` — `voice` or `image` +- `sessionId` — 8 hex chars +- `requesterKey6` — copied from the swarm request +- `responderKey6` — 12 hex chars identifying the responding peer +- `availableCount` — `0` means the responder can satisfy the full request +- `availableIndices` — exact fragment indices held when `availableCount > 0` + +Example: + +```text +6d 02 02 de ad be ef aa bb cc dd ee ff 11 22 33 44 55 66 02 02 09 +``` + +## 4. Requester Behavior + +When a voice or image session is incomplete: + +1. Prefer the original sender if its direct raw route is healthy. +2. If the original sender path is unavailable or not responding, send a raw + swarm request to reachable peers with the exact missing fragment indices. +3. Wait up to **10 seconds** for raw availability responses. +4. Rank responders by overlap with the current missing set. +5. Skip the original sender when choosing alternate peers. +6. Send a direct binary fetch request to the best responder for only the + missing subset that responder advertised. + +Actual media transfer uses the same `cmdSendRawData` / `pushRawData` path as the +swarm control messages. + +## 5. Responder Behavior + +A peer that receives a swarm request: + +- checks whether it has fragments for the requested media session +- replies only if it has at least one requested fragment +- advertises only fragments it actually holds +- may reply from: + - its outgoing cache, or + - a partially/fully received incoming session + +This enables torrent-like relay behavior without requiring the peer to be the +original sender. + +## 6. Visibility and Routing + +- Swarm control payloads are carried by `cmdSendRawData` and received through + `pushRawData`. +- They are intercepted by the app and **must not be surfaced in chat**. +- Discovery is a direct raw fan-out to reachable peers, not a public-channel + broadcast. +- Media packets themselves remain direct-route raw packets. + +## 7. Constraints + +- No firmware changes are required. +- Swarm mode only helps if at least one peer has already received some useful + fragments. +- Peers can only relay fragments they actually have. +- Alternate peers still need a valid direct raw route back to the requester. +- Swarm mode improves recovery from sender-path failure, but it does not change + the underlying raw-packet size, airtime, or hop constraints. + +## 8. High-Level Sequence + +```mermaid +sequenceDiagram + participant S as Original Sender + participant P as Peer With Fragments + participant N as Reachable Peers + participant R as Requester + + S->>R: Direct raw fragments (partial) + Note over S,R: Original sender path stops responding + R->>N: Raw swarm requests with exact missing fragments + P->>R: Raw swarm availability response + R->>P: Direct binary fetch request for missing subset + P->>R: Direct raw fragments + R->>R: Reassemble completed session +``` + +## 9. Integration Points + +- Voice-specific behavior is summarized in `docs/voice-mode-technical.md`. +- Image-specific behavior is summarized in `docs/image-mode-technical.md`. +- This document is the shared source of truth for swarm discovery and fallback + semantics. diff --git a/docs/voice-mode-technical.md b/docs/voice-mode-technical.md new file mode 100644 index 0000000..9861dae --- /dev/null +++ b/docs/voice-mode-technical.md @@ -0,0 +1,255 @@ +# Voice Mode Technical Design + +## 1. Overview + +Voice mode uses a **two-plane architecture** with optional swarm-assisted +recovery: + +- **Control plane (text messages):** + - `VE3:` voice envelope announces voice availability in chat. +- **Control plane (raw binary request):** + - Binary voice fetch request (same raw route as voice packets). +- **Data plane (raw binary packets):** + - `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`. + +This design avoids broadcasting full voice payloads to channels/rooms. Chat carries only metadata; audio is fetched on demand when user presses play. + +Swarm fallback is documented in [Swarm Mode Technical Design](./swarm-mode-technical.md). + +## 2. Key Modules + +- `lib/utils/voice_message_parser.dart` + - `VoicePacket` (binary direct-packet format) + - `VoiceEnvelope` (`VE3`) + - `VoiceFetchRequest` (binary) +- `lib/screens/messages_tab.dart` + - Capture/encode voice, cache encoded packets, send envelope only +- `lib/providers/voice_provider.dart` + - Reassembly/playback sessions + - Outgoing session cache + deferred serving +- `lib/providers/app_provider.dart` + - Incoming routing for `VE3`, binary voice fetch requests, and raw swarm control payloads + - Handles raw packet ingestion +- `lib/widgets/messages/voice_message_bubble.dart` + - Play behavior (immediate play if complete, otherwise fetch + auto-play) +- `lib/providers/messages_provider.dart` + - Message-level voice detection (`VE3`) +- `lib/services/message_storage_service.dart` + - Persists `isVoice` and `voiceId` + +## 3. Wire Formats + +### 3.1 Voice Envelope (`VE3`) + +Prefix: `VE3:` + colon-delimited compact payload (base36 numeric fields) + +Fields: + +- `sid` (string): base36 token for 32-bit session ID +- `mode` (base36): codec mode ID (`VoicePacketMode.id`) +- `total` (base36): packet count (1..255) +- `durS` (base36): estimated duration in seconds + +`sid` is base36 on wire and expands to 8-hex internally. + +Compact format: + +```text +VE3:{sid}:{mode}:{total}:{durS} +``` + +Example: + +```text +VE3:a:1:4:4 +``` + +### 3.2 Voice Fetch Request (binary) + +Binary payload format: + +```text +[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...] +``` + +### 3.3 Raw Voice Packet (data plane) + +Binary payload structure: + +- Byte 0: magic `0x56` (`'V'`) +- Bytes 1..4: session ID (4 bytes) +- Byte 5: packet index +- Bytes 6..N: codec2 data + +Header is 6 bytes. Mode and total packet count come from the `VE3` envelope. + +## 4. Outgoing Flow (Send) + +1. Recorder captures PCM chunks. +2. Each chunk is codec2-encoded into `VoicePacket` objects. +3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min). +4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`). +5. Sender sends one envelope (`VE3`) through normal message path: + - channel/room: `sendChannelMessage` + - direct: `sendTextMessage` +6. **No raw audio packets are sent during initial send.** + +## 5. Incoming Routing + +### 5.1 `VE3` envelope received + +`AppProvider` records sender identity from message metadata, registers the voice +session envelope, marks the message as voice (`isVoice`, `voiceId`), and adds it to chat. + +### 5.2 Binary voice fetch request received + +`AppProvider` treats it as control-plane only: + +- request is not added to chat +- resolves requester contact via key prefix +- calls `voiceProvider.serveSessionTo(...)` + +### 5.3 Swarm control messages received + +`AppProvider` also handles raw swarm control payloads: + +- binary swarm requests advertise which voice fragments are still missing +- binary swarm availability responses advertise which fragments a peer can relay +- swarm control payloads arrive via `pushRawData` and are not added to chat history + +Swarm semantics are shared with image mode and documented in +[Swarm Mode Technical Design](./swarm-mode-technical.md). + +### 5.4 Raw packet received (`pushRawData`) + +`AppProvider.onRawDataReceived` parses `VoicePacket` binary and appends to session in `VoiceProvider`. + +## 6. Play / Fetch Behavior + +In `VoiceMessageBubble`: + +- If session already complete: play immediately. +- If incomplete/missing: + 1. Resolve sender contact from message sender metadata + 2. Prefer a direct fetch from the original sender if its raw route is healthy + 3. If the sender path does not respond, fan out a raw swarm request with the + exact missing packet indices to reachable peers + 4. Wait up to 10 seconds for raw peer availability responses + 5. Send a direct binary fetch request to the best alternate peer for the + missing subset it advertised + 6. Show requesting state in UI + 7. Auto-play when session becomes complete + +If sender cannot be resolved or request cannot be sent, bubble remains and shows: **"Voice unavailable right now"**. + +## 7. Outgoing Cache Details + +`VoiceProvider` outgoing cache: + +- key: `sessionId` +- value: encoded packet list + cached timestamp +- TTL: 15 minutes (`_outgoingSessionTtl`) +- eviction: lazy (on cache access/add/serve) + +Serving prerequisites: + +- session exists in outgoing cache or already-received session state +- `sendRawPacketCallback` configured +- requester has direct path (`outPathLen >= 0`) + +Received partial sessions can therefore act as relay sources during swarm +recovery. + +## 8. Persistence + +`MessageStorageService` now stores and restores: + +- `isVoice` +- `voiceId` + +This ensures envelope messages remain voice bubbles across app restart. + +## 9. Validation and Safety + +Parser validation enforces: + +- strict hex lengths for IDs and key prefixes +- valid mode range +- valid packet counts and duration bounds +- compact base36 numeric fields in envelope/request +- Binary request flags specify `all` or `missing` indices. +- Request payload includes `requesterKey6` to resolve return route. + +## 10. Transmit Time Estimate (UI) + +Voice bubbles and Message Technical Details show an **estimated transmit time** (`~... tx`). + +The estimate is airtime-based (LoRa packet model), not file-duration-only: + +- Source inputs: + - `packetCount` and `durationMs` from `VE3` envelope, or + - numeric envelope values decoded from base36 + - actual received `VoicePacket.codec2Data.length` bytes when local session packets exist + - `pathLen` from message metadata + - current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr` +- Per-packet payload model: + - `meshHeader(2)` + `pathLen` + `voiceHeader(6)` + `codec2Bytes` +- LoRa airtime: + - standard symbol-time formula (preamble + payload symbols) +- Mesh pacing/hops: + - multiplied by `(1 + airtimeBudgetFactor)` where default factor is `1.0` + - multiplied by hop count `(pathLen + 1)` +- Total estimate: + - sum over all packets + +BW handling: + +- If `radioBw` is index `0..9`, app maps it to Hz (`7.8k` .. `500k`) +- If `radioBw > 1000`, it is treated as Hz directly + +Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`, `CR5`. + +## 11. Operational Constraints + +- No firmware changes required. +- On-demand fetch prefers the original sender, but a partial session can also be + completed from alternate peers that already hold packets. +- Raw return path needs a currently valid direct route to requester. +- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`). +- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as voice + fetch and packet delivery. + +### 11.1 Raw Binary Routing Semantics + +- Voice payload packets use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`). +- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`). +- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`). +- This flow is direct-route only, not flood/broadcast: + - it is sent to one destination path; + - only nodes on that path relay it; + - it is **not** received by everyone in the mesh. + +## 12. High-Level Sequence + +```mermaid +sequenceDiagram + participant A as Sender App + participant P as Peer With Packets + participant N as Reachable Peers + participant B as Receiver App + + A->>A: Record + encode voice packets + A->>A: Cache session packets (TTL 15m) + A->>M: Send VE3 envelope + M->>B: Deliver VE3 + B->>B: Render voice bubble (metadata only) + B->>A: Send binary fetch request on Play + A->>B: Stream raw VoicePacket packets (partial) + Note over A,B: Sender path stops responding + B->>N: Raw swarm requests with missing voice packet indices + P->>B: Raw swarm availability response + B->>P: Direct binary fetch request for missing subset + P->>B: Stream remaining raw VoicePacket packets + B->>B: Reassemble session + B->>B: Auto-play when complete +``` diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..6753d4a Binary files /dev/null and b/icon.png differ diff --git a/integration_test/app_screenshots_test.dart b/integration_test/app_screenshots_test.dart new file mode 100644 index 0000000..aa6aba0 --- /dev/null +++ b/integration_test/app_screenshots_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/main.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/screens/home_screen.dart'; +import 'package:meshcore_sar_app/services/wizard_preferences.dart'; +import 'package:meshcore_sar_app/utils/sample_data_generator.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + const screenshotPrefix = String.fromEnvironment('SCREENSHOT_PREFIX'); + + testWidgets('captures App Store screenshots', (tester) async { + SharedPreferences.setMockInitialValues({ + 'wizard_completed': true, + 'wizard_version': 1, + }); + await WizardPreferences.setWizardCompleted(true); + + await tester.pumpWidget(const MeshCoreSarApp()); + await _pumpUntilFound(tester, find.byType(HomeScreen)); + await binding.convertFlutterSurfaceToImage(); + + final homeContext = tester.element(find.byType(HomeScreen)); + _loadSampleData(homeContext); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + await _takeScreenshot(binding, tester, '${screenshotPrefix}01-messages'); + + await _tapTab(tester, 'Contacts'); + await _takeScreenshot(binding, tester, '${screenshotPrefix}02-contacts'); + + await _tapTab(tester, 'Map'); + await tester.pumpAndSettle(const Duration(seconds: 2)); + await _takeScreenshot(binding, tester, '${screenshotPrefix}03-map'); + + await _openOverflowItem(tester, Icons.more_vert, 'Settings'); + await _takeScreenshot(binding, tester, '${screenshotPrefix}04-settings'); + }); +} + +void _loadSampleData(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + const centerLocation = LatLng(46.0569, 14.5058); + final contacts = SampleDataGenerator.generateContacts( + centerLocation: centerLocation, + l10n: l10n, + teamMemberCount: 5, + channelCount: 2, + ); + final sampleMessages = SampleDataGenerator.generateAllMessages( + centerLocation: centerLocation, + l10n: l10n, + foundPersonCount: 2, + fireCount: 1, + stagingCount: 1, + objectCount: 1, + generalChannelMessages: 8, + emergencyChannelMessages: 5, + ); + context.read().addContacts(contacts); + for (final message in sampleMessages.messages) { + context.read().addMessage( + message, + contactLocationSnapshot: sampleMessages.contactLocations[message.id], + ); + } +} + +Future _pumpUntilFound( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 10), +}) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isNotEmpty) { + return; + } + } + expect(finder, findsOneWidget); +} + +Future _tapTab(WidgetTester tester, String label) async { + final tab = find.text(label).last; + await tester.tap(tab); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); +} + +Future _openOverflowItem( + WidgetTester tester, + IconData buttonIcon, + String label, +) async { + await tester.tap(find.byIcon(buttonIcon)); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(const Duration(seconds: 1)); +} + +Future _takeScreenshot( + IntegrationTestWidgetsFlutterBinding binding, + WidgetTester tester, + String name, +) async { + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + final bytes = await binding.takeScreenshot(name); + expect(bytes.isNotEmpty, isTrue); +} diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..57b8fb0 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,3 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" +ENABLE_DEBUG_DYLIB = NO diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Gemfile b/ios/Gemfile new file mode 100644 index 0000000..7a118b4 --- /dev/null +++ b/ios/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock new file mode 100644 index 0000000..4b9cf7b --- /dev/null +++ b/ios/Gemfile.lock @@ -0,0 +1,228 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.9) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.3.2) + aws-partitions (1.1109.0) + aws-sdk-core (3.224.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.101.0) + aws-sdk-core (~> 3, >= 3.216.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.188.0) + aws-sdk-core (~> 3, >= 3.224.1) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.11.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.109.0) + faraday (1.10.5) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.229.0) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.32.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.6.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.3.1) + google-cloud-storage (1.37.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.1) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.7.6) + jwt (2.10.3) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + optparse (0.8.1) + os (1.1.4) + plist (3.7.2) + public_suffix (5.1.1) + rake (13.4.2) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.18.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unf (0.2.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.4.22 diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..b24e2b1 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,51 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + # codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that + # fails to compile for iOS simulators. Voice codec support is only required + # on real iOS devices, so skip the native Codec2 sources for simulator SDKs. + if target.name == 'codec2_flutter' + target.build_configurations.each do |config| + config.build_settings['EXCLUDED_SOURCE_FILE_NAMES[sdk=iphonesimulator*]'] = '*.c *.cpp' + end + end + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..af0de8d --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,52 @@ +PODS: + - codec2_flutter (0.1.0): + - Flutter + - Flutter (1.0.0) + - flutter_avif_ios (0.0.1): + - Flutter + - flutter_background_service_ios (0.0.3): + - Flutter + - flutter_compass (0.0.1): + - Flutter + - nsd_ios (0.0.1): + - Flutter + - permission_handler_apple (9.3.0): + - Flutter + +DEPENDENCIES: + - codec2_flutter (from `.symlinks/plugins/codec2_flutter/ios`) + - Flutter (from `Flutter`) + - flutter_avif_ios (from `.symlinks/plugins/flutter_avif_ios/ios`) + - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) + - flutter_compass (from `.symlinks/plugins/flutter_compass/ios`) + - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + +EXTERNAL SOURCES: + codec2_flutter: + :path: ".symlinks/plugins/codec2_flutter/ios" + Flutter: + :path: Flutter + flutter_avif_ios: + :path: ".symlinks/plugins/flutter_avif_ios/ios" + flutter_background_service_ios: + :path: ".symlinks/plugins/flutter_background_service_ios/ios" + flutter_compass: + :path: ".symlinks/plugins/flutter_compass/ios" + nsd_ios: + :path: ".symlinks/plugins/nsd_ios/ios" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + +SPEC CHECKSUMS: + codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_avif_ios: 2553d2aafda56339cf924e943da89ad3f40af2dd + flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e + flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1 + nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + +PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878 + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1b51419 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,779 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 6E07C83B534F125C2CBE6788 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 95A961E69DFB3804DF041D16 /* Pods_Runner.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B117D6FC7E214B5BDF078335 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E125ECD1538D6FD469DAD40 /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0A6847CB76DAC5A39547F092 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 11010AF1B9B58058CB45D836 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2FF0A626EBAC324A9D2CD7C8 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3B8CF791ABE83A2374700CB3 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 5E125ECD1538D6FD469DAD40 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 88D332C6C1988532993F282B /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 8E8E561633FC42AD799C3EC6 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 95A961E69DFB3804DF041D16 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 63128E4122D07543FBC5706D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B117D6FC7E214B5BDF078335 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 6E07C83B534F125C2CBE6788 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 3C28992F8CC08CA6C1F157CE /* Pods */ = { + isa = PBXGroup; + children = ( + 0A6847CB76DAC5A39547F092 /* Pods-Runner.debug.xcconfig */, + 88D332C6C1988532993F282B /* Pods-Runner.release.xcconfig */, + 3B8CF791ABE83A2374700CB3 /* Pods-Runner.profile.xcconfig */, + 8E8E561633FC42AD799C3EC6 /* Pods-RunnerTests.debug.xcconfig */, + 2FF0A626EBAC324A9D2CD7C8 /* Pods-RunnerTests.release.xcconfig */, + 11010AF1B9B58058CB45D836 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 3C28992F8CC08CA6C1F157CE /* Pods */, + 9C44871016BF8D8E5F823EE9 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + 9C44871016BF8D8E5F823EE9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 95A961E69DFB3804DF041D16 /* Pods_Runner.framework */, + 5E125ECD1538D6FD469DAD40 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + E57C8F1FC76A13F8392A3A72 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 63128E4122D07543FBC5706D /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 142118FEB8CC97859621AD22 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 0F5356670725D838348C6698 /* [CP] Embed Pods Frameworks */, + 5C71E5FAEF51EAE4BBF3727A /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0F5356670725D838348C6698 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 142118FEB8CC97859621AD22 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 5C71E5FAEF51EAE4BBF3727A /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + E57C8F1FC76A13F8392A3A72 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 142; + DEVELOPMENT_TEAM = JND55328G8; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8E8E561633FC42AD799C3EC6 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 142; + DEVELOPMENT_TEAM = JND55328G8; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2FF0A626EBAC324A9D2CD7C8 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 142; + DEVELOPMENT_TEAM = JND55328G8; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 11010AF1B9B58058CB45D836 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 142; + DEVELOPMENT_TEAM = JND55328G8; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 142; + DEVELOPMENT_TEAM = JND55328G8; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 142; + DEVELOPMENT_TEAM = JND55328G8; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6098368 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,15 @@ +import UIKit +import Flutter +import UserNotifications + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x-backup.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x-backup.png new file mode 100644 index 0000000..7f5c441 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x-backup.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..5dbed36 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..d4c4f1a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..b048832 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..0120ab7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..28bcbb6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..54750ad Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..45216fd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..b048832 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..66c871c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..dde0afd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..b5c100c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..4a7ea9d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..bb4a242 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..c1a2bc2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..dde0afd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e0877d7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..fd20a8e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..41d83ae Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..a423f4c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..842d351 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..f25ae33 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/Contents.json b/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..db8c0cb --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,158 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + dev.flutter.background.refresh + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + MeshCore SAR + CFBundleDocumentTypes + + + CFBundleTypeName + MBTiles Map File + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.database + public.data + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MeshCore SAR + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + 142 + LSRequiresIPhoneOS + + ITSAppUsesNonExemptEncryption + + NSBluetoothAlwaysUsageDescription + MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations + NSBluetoothPeripheralUsageDescription + MeshCore SAR needs Bluetooth to communicate with MeshCore devices + NSBonjourServices + + _meshcore-sse._tcp + + NSLocalNetworkUsageDescription + MeshCore SAR needs local network access to discover and connect to shared MeshCore devices on your network for team coordination during SAR operations + NSLocationAlwaysAndWhenInUseUsageDescription + MeshCore SAR needs location access for offline map functionality during field operations + NSLocationDefaultAccuracyReduced + + NSLocationTemporaryPreciseUsageDescription + MeshCore SAR needs precise location for accurate positioning in SAR operations + NSLocationWhenInUseUsageDescription + MeshCore SAR needs location access to display team members and SAR markers on the map + NSMotionUsageDescription + MeshCore SAR needs access to the compass to show your heading direction on the map + NSMicrophoneUsageDescription + MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations + NSCameraUsageDescription + MeshCore SAR needs camera access to take photos and attach them to SAR messages + NSPhotoLibraryUsageDescription + MeshCore SAR may need access to your photo library to attach images to messages or save map screenshots for documentation during SAR operations + NSPhotoLibraryAddUsageDescription + MeshCore SAR needs permission to save exported screenshots and SAR documentation images to your photo library + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + location + bluetooth-central + bluetooth-peripheral + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportsDocumentBrowser + + UIUserNotificationSettings + + UIUserNotificationTypesEnabled + + UIUserNotificationTypeAlert + UIUserNotificationTypeBadge + UIUserNotificationTypeSound + + + UTImportedTypeDeclarations + + + UTTypeConformsTo + + public.database + public.data + + UTTypeDescription + MBTiles Map Archive + UTTypeIdentifier + com.mapbox.mbtiles + UTTypeTagSpecification + + public.filename-extension + + mbtiles + + + + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile new file mode 100644 index 0000000..7cb1c0a --- /dev/null +++ b/ios/fastlane/Fastfile @@ -0,0 +1,81 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +default_platform(:ios) + +platform :ios do + def project_root + File.expand_path("../..", __dir__) + end + + def beta_app_info + { + "en-US" => { + description: "Field-ready SAR coordination over MeshCore with chat, voice, images, offline maps, and live team context.", + feedback_email: "hey@dz0ny.dev", + marketing_url: "https://github.com/dz0ny/meshcore-sar", + privacy_policy_url: "https://github.com/dz0ny/meshcore-sar" + } + } + end + + def beta_build_info + { + "en-US" => { + whats_new: "Test MeshCore SAR field coordination flows: messaging, maps, contacts, voice, images, and SAR markers." + } + } + end + + desc "Push a new release build to the App Store" + lane :release do + increment_build_number(xcodeproj: "Runner.xcodeproj") + build_app(workspace: "Runner.xcworkspace", scheme: "Runner") + upload_to_testflight( + skip_waiting_for_build_processing: true, + localized_app_info: beta_app_info, + localized_build_info: beta_build_info, + changelog: "Test MeshCore SAR field coordination flows: messaging, maps, contacts, voice, images, and SAR markers." + ) + end + + desc "Push a new beta build to TestFlight" + lane :beta do + increment_build_number(xcodeproj: "Runner.xcodeproj") + build_app(workspace: "Runner.xcworkspace", scheme: "Runner") + upload_to_testflight( + skip_waiting_for_build_processing: true, + localized_app_info: beta_app_info, + localized_build_info: beta_build_info, + changelog: "Test MeshCore SAR field coordination flows: messaging, maps, contacts, voice, images, and SAR markers." + ) + end + + desc "Capture iOS screenshots for App Store Connect" + lane :screenshots do + sh("cd #{project_root} && scripts/take_screenshots.sh --ios") + end + + desc "Upload App Store screenshots" + lane :store_assets do + upload_to_app_store( + skip_binary_upload: true, + skip_metadata: true, + skip_screenshots: false, + submit_for_review: false, + force: true + ) + end +end diff --git a/ios/fastlane/README.md b/ios/fastlane/README.md new file mode 100644 index 0000000..0c9bf8b --- /dev/null +++ b/ios/fastlane/README.md @@ -0,0 +1,56 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios release + +```sh +[bundle exec] fastlane ios release +``` + +Push a new release build to the App Store + +### ios beta + +```sh +[bundle exec] fastlane ios beta +``` + +Push a new beta build to TestFlight + +### ios screenshots + +```sh +[bundle exec] fastlane ios screenshots +``` + +Capture iOS screenshots for App Store Connect + +### ios store_assets + +```sh +[bundle exec] fastlane ios store_assets +``` + +Upload App Store screenshots + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/ios/fastlane/metadata/copyright.txt b/ios/fastlane/metadata/copyright.txt new file mode 100644 index 0000000..a79da20 --- /dev/null +++ b/ios/fastlane/metadata/copyright.txt @@ -0,0 +1 @@ +Copyright 2026 Janez Troha diff --git a/ios/fastlane/metadata/en-US/description.txt b/ios/fastlane/metadata/en-US/description.txt new file mode 100644 index 0000000..40521b9 --- /dev/null +++ b/ios/fastlane/metadata/en-US/description.txt @@ -0,0 +1,14 @@ +MeshCore SAR gives search and rescue teams a field-ready coordination workspace for low-connectivity operations. + +Use mesh chat, live location context, SAR markers, offline-first maps, tactical drawings, voice clips, and compressed image transfer in one app. The app is built around MeshCore and BLE so teams can coordinate over LoRa links when normal infrastructure is weak or unavailable. + +Key capabilities: +- Direct and group mesh messaging +- Live team contacts with signal, battery, and location context +- Offline maps with SAR markers and tactical overlays +- Voice clips optimized for low-bandwidth links +- Image transfer with compact encoding +- GPX trail import/export +- Incident, staging, and discovery workflows for field teams + +MeshCore SAR is intended for search and rescue field teams, incident command, and operators who need practical coordination tools outside normal coverage. diff --git a/ios/fastlane/metadata/en-US/keywords.txt b/ios/fastlane/metadata/en-US/keywords.txt new file mode 100644 index 0000000..d53e509 --- /dev/null +++ b/ios/fastlane/metadata/en-US/keywords.txt @@ -0,0 +1 @@ +meshcore,sar,search,rescue,mesh,lora,offline,maps,radio,gps diff --git a/ios/fastlane/metadata/en-US/marketing_url.txt b/ios/fastlane/metadata/en-US/marketing_url.txt new file mode 100644 index 0000000..2392c93 --- /dev/null +++ b/ios/fastlane/metadata/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://github.com/dz0ny/meshcore-sar diff --git a/ios/fastlane/metadata/en-US/name.txt b/ios/fastlane/metadata/en-US/name.txt new file mode 100644 index 0000000..e9dc922 --- /dev/null +++ b/ios/fastlane/metadata/en-US/name.txt @@ -0,0 +1 @@ +MeshCore SAR diff --git a/ios/fastlane/metadata/en-US/privacy_url.txt b/ios/fastlane/metadata/en-US/privacy_url.txt new file mode 100644 index 0000000..2392c93 --- /dev/null +++ b/ios/fastlane/metadata/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://github.com/dz0ny/meshcore-sar diff --git a/ios/fastlane/metadata/en-US/promotional_text.txt b/ios/fastlane/metadata/en-US/promotional_text.txt new file mode 100644 index 0000000..9729f7b --- /dev/null +++ b/ios/fastlane/metadata/en-US/promotional_text.txt @@ -0,0 +1 @@ +Field coordination for search and rescue teams using MeshCore, BLE, offline maps, messaging, voice, and SAR markers. diff --git a/ios/fastlane/metadata/en-US/release_notes.txt b/ios/fastlane/metadata/en-US/release_notes.txt new file mode 100644 index 0000000..3989c04 --- /dev/null +++ b/ios/fastlane/metadata/en-US/release_notes.txt @@ -0,0 +1 @@ +Adds App Store screenshots and TestFlight metadata for MeshCore SAR publishing. diff --git a/ios/fastlane/metadata/en-US/subtitle.txt b/ios/fastlane/metadata/en-US/subtitle.txt new file mode 100644 index 0000000..08f4779 --- /dev/null +++ b/ios/fastlane/metadata/en-US/subtitle.txt @@ -0,0 +1 @@ +SAR mesh field coordination diff --git a/ios/fastlane/metadata/en-US/support_url.txt b/ios/fastlane/metadata/en-US/support_url.txt new file mode 100644 index 0000000..f92d4d8 --- /dev/null +++ b/ios/fastlane/metadata/en-US/support_url.txt @@ -0,0 +1 @@ +https://github.com/dz0ny/meshcore-sar/issues diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml new file mode 100644 index 0000000..bcc9d06 --- /dev/null +++ b/ios/fastlane/report.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/fastlane/screenshots/en-US/01-messages.png b/ios/fastlane/screenshots/en-US/01-messages.png new file mode 100644 index 0000000..43a8c7a Binary files /dev/null and b/ios/fastlane/screenshots/en-US/01-messages.png differ diff --git a/ios/fastlane/screenshots/en-US/02-contacts.png b/ios/fastlane/screenshots/en-US/02-contacts.png new file mode 100644 index 0000000..af0bb08 Binary files /dev/null and b/ios/fastlane/screenshots/en-US/02-contacts.png differ diff --git a/ios/fastlane/screenshots/en-US/03-map.png b/ios/fastlane/screenshots/en-US/03-map.png new file mode 100644 index 0000000..cf5677e Binary files /dev/null and b/ios/fastlane/screenshots/en-US/03-map.png differ diff --git a/ios/fastlane/screenshots/en-US/04-settings.png b/ios/fastlane/screenshots/en-US/04-settings.png new file mode 100644 index 0000000..1ddc182 Binary files /dev/null and b/ios/fastlane/screenshots/en-US/04-settings.png differ diff --git a/ios/fastlane/screenshots/en-US/ipad-01-messages.png b/ios/fastlane/screenshots/en-US/ipad-01-messages.png new file mode 100644 index 0000000..7990048 Binary files /dev/null and b/ios/fastlane/screenshots/en-US/ipad-01-messages.png differ diff --git a/ios/fastlane/screenshots/en-US/ipad-02-contacts.png b/ios/fastlane/screenshots/en-US/ipad-02-contacts.png new file mode 100644 index 0000000..31bd622 Binary files /dev/null and b/ios/fastlane/screenshots/en-US/ipad-02-contacts.png differ diff --git a/ios/fastlane/screenshots/en-US/ipad-03-map.png b/ios/fastlane/screenshots/en-US/ipad-03-map.png new file mode 100644 index 0000000..cc53bd6 Binary files /dev/null and b/ios/fastlane/screenshots/en-US/ipad-03-map.png differ diff --git a/ios/fastlane/screenshots/en-US/ipad-04-settings.png b/ios/fastlane/screenshots/en-US/ipad-04-settings.png new file mode 100644 index 0000000..9307995 Binary files /dev/null and b/ios/fastlane/screenshots/en-US/ipad-04-settings.png differ diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..1c7e2fa --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +untranslated-messages-file: lib/l10n/untranslated.json diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb new file mode 100644 index 0000000..018ac6f --- /dev/null +++ b/lib/l10n/app_de.arb @@ -0,0 +1,1999 @@ +{ + "@@locale": "de", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "Der Anwendungstitel" + }, + "messages": "Nachrichten", + "@messages": { + "description": "Beschriftung der Nachrichten-Registerkarte" + }, + "contacts": "Kontakte", + "@contacts": { + "description": "Beschriftung der Kontakte-Registerkarte" + }, + "map": "Karte", + "@map": { + "description": "Beschriftung der Karten-Registerkarte" + }, + "settings": "Einstellungen", + "@settings": { + "description": "Titel des Einstellungsbildschirms" + }, + "connect": "Verbinden", + "@connect": { + "description": "Beschriftung der Verbinden-Schaltfläche" + }, + "disconnect": "Trennen", + "@disconnect": { + "description": "Beschriftung der Trennen-Schaltfläche" + }, + "noDevicesFound": "Keine Geräte gefunden", + "@noDevicesFound": { + "description": "Text, der angezeigt wird, wenn keine BLE-Geräte gefunden wurden" + }, + "scanAgain": "Erneut scannen", + "@scanAgain": { + "description": "Schaltfläche zum Neustart des BLE-Scans" + }, + "deviceNotConnected": "Gerät nicht verbunden", + "@deviceNotConnected": { + "description": "Fehlermeldung, wenn das Gerät nicht verbunden ist" + }, + "locationPermissionDenied": "Standortberechtigung verweigert", + "@locationPermissionDenied": { + "description": "Fehler, wenn die Standortberechtigung verweigert wird" + }, + "locationPermissionPermanentlyDenied": "Standortberechtigung dauerhaft verweigert. Bitte in den Einstellungen aktivieren.", + "@locationPermissionPermanentlyDenied": { + "description": "Fehler, wenn die Standortberechtigung dauerhaft verweigert wird" + }, + "locationPermissionRequired": "Die Standortberechtigung ist für GPS-Tracking und Teamkoordination erforderlich. Sie können sie später in den Einstellungen aktivieren.", + "@locationPermissionRequired": { + "description": "Nachricht, wenn Standortberechtigung benötigt wird" + }, + "locationServicesDisabled": "Standortdienste sind deaktiviert. Bitte aktivieren Sie sie in den Einstellungen.", + "@locationServicesDisabled": { + "description": "Fehler, wenn Standortdienste deaktiviert sind" + }, + "failedToGetGpsLocation": "GPS-Position konnte nicht abgerufen werden", + "@failedToGetGpsLocation": { + "description": "Fehler, wenn die GPS-Position nicht abgerufen werden kann" + }, + "failedToAdvertise": "Senden fehlgeschlagen: {error}", + "@failedToAdvertise": { + "description": "Fehlermeldung bei fehlgeschlagenem Senden", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "Wiederverbindung abbrechen", + "@cancelReconnection": { + "description": "Tooltip für die Schaltfläche zum Abbrechen der Wiederverbindung" + }, + "general": "Allgemein", + "@general": { + "description": "Überschrift des allgemeinen Einstellungsbereichs" + }, + "theme": "Design", + "@theme": { + "description": "Beschriftung der Design-Einstellung" + }, + "chooseTheme": "Design auswählen", + "@chooseTheme": { + "description": "Titel des Design-Auswahldialogs" + }, + "light": "Hell", + "@light": { + "description": "Helles Design-Option" + }, + "dark": "Dunkel", + "@dark": { + "description": "Dunkles Design-Option" + }, + "blueLightTheme": "Blaues helles Design", + "@blueLightTheme": { + "description": "Beschreibung für blaues helles Design" + }, + "blueDarkTheme": "Blaues dunkles Design", + "@blueDarkTheme": { + "description": "Beschreibung für blaues dunkles Design" + }, + "sarRed": "SAR Rot", + "@sarRed": { + "description": "SAR Rot Design-Option" + }, + "alertEmergencyMode": "Alarm-/Notfallmodus", + "@alertEmergencyMode": { + "description": "Beschreibung für SAR Rot Design" + }, + "sarGreen": "SAR Grün", + "@sarGreen": { + "description": "SAR Grün Design-Option" + }, + "safeAllClearMode": "Sicher/Entwarnung-Modus", + "@safeAllClearMode": { + "description": "Beschreibung für SAR Grün Design" + }, + "autoSystem": "Automatisch (System)", + "@autoSystem": { + "description": "Automatisches/System-Design-Option" + }, + "followSystemTheme": "System-Design folgen", + "@followSystemTheme": { + "description": "Beschreibung für System-Design" + }, + "showRxTxIndicators": "RX/TX-Indikatoren anzeigen", + "@showRxTxIndicators": { + "description": "Einstellung zum Anzeigen von RX/TX-Indikatoren" + }, + "disableMap": "Karte deaktivieren", + "@disableMap": { + "description": "Einstellung zum Deaktivieren des Karten-Tabs" + }, + "language": "Sprache", + "@language": { + "description": "Beschriftung der Spracheinstellung" + }, + "chooseLanguage": "Sprache auswählen", + "@chooseLanguage": { + "description": "Titel des Sprachauswahldialogs" + }, + "save": "Speichern", + "@save": { + "description": "Beschriftung der Speichern-Schaltfläche" + }, + "cancel": "Abbrechen", + "@cancel": { + "description": "Beschriftung der Abbrechen-Schaltfläche" + }, + "close": "Schließen", + "@close": { + "description": "Beschriftung der Schließen-Schaltfläche" + }, + "about": "Über", + "@about": { + "description": "Überschrift des Über-Bereichs" + }, + "appVersion": "App-Version", + "@appVersion": { + "description": "Beschriftung der App-Version" + }, + "aboutMeshCoreSar": "Über MeshCore SAR", + "@aboutMeshCoreSar": { + "description": "Titel des Über-Dialogs" + }, + "aboutDescription": "Eine Such- und Rettungsanwendung für Notfallteams. Funktionen umfassen:\n\n• BLE-Mesh-Netzwerk für Gerät-zu-Gerät-Kommunikation\n• Offline-Karten mit mehreren Ebenenoptionen\n• Echtzeit-Teammitgliederverfolgung\n• SAR-Taktikmarkierungen (Person gefunden, Feuer, Sammelpunkt)\n• Kontaktverwaltung und Nachrichtenübermittlung\n• GPS-Tracking mit Kompass-Kurs\n• Karten-Tile-Caching für Offline-Nutzung", + "@aboutDescription": { + "description": "Beschreibung des Über-Dialogs" + }, + "technologiesUsed": "Verwendete Technologien:", + "@technologiesUsed": { + "description": "Titel des Abschnitts verwendete Technologien" + }, + "technologiesList": "• Flutter für plattformübergreifende Entwicklung\n• BLE (Bluetooth Low Energy) für Mesh-Netzwerk\n• OpenStreetMap für Kartendarstellung\n• Provider für Zustandsverwaltung\n• SharedPreferences für lokale Speicherung", + "@technologiesList": { + "description": "Liste der verwendeten Technologien" + }, + "moreInfo": "Mehr Info", + "@moreInfo": { + "description": "Beschriftung für Mehr Info-Button" + }, + "packageName": "Paketname", + "@packageName": { + "description": "Beschriftung des Paketnamens" + }, + "sampleData": "Beispieldaten", + "@sampleData": { + "description": "Überschrift des Beispieldaten-Bereichs" + }, + "sampleDataDescription": "Laden oder löschen Sie Beispielkontakte, Kanalnachrichten und SAR-Markierungen zum Testen", + "@sampleDataDescription": { + "description": "Beschreibung des Beispieldaten-Bereichs" + }, + "loadSampleData": "Beispieldaten laden", + "@loadSampleData": { + "description": "Schaltfläche zum Laden von Beispieldaten" + }, + "clearAllData": "Alle Daten löschen", + "@clearAllData": { + "description": "Schaltfläche zum Löschen aller Daten" + }, + "clearAllDataConfirmTitle": "Alle Daten löschen", + "@clearAllDataConfirmTitle": { + "description": "Titel des Dialogs zur Bestätigung des Löschens von Daten" + }, + "clearAllDataConfirmMessage": "Dadurch werden alle Kontakte und SAR-Markierungen gelöscht. Sind Sie sicher?", + "@clearAllDataConfirmMessage": { + "description": "Bestätigungsnachricht zum Löschen von Daten" + }, + "clear": "Löschen", + "@clear": { + "description": "Beschriftung der Löschen-Schaltfläche" + }, + "loadedSampleData": "{teamCount} Teammitglieder, {channelCount} Kanäle, {sarCount} SAR-Markierungen, {messageCount} Nachrichten geladen", + "@loadedSampleData": { + "description": "Erfolgsmeldung nach dem Laden von Beispieldaten", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "Fehler beim Laden der Beispieldaten: {error}", + "@failedToLoadSampleData": { + "description": "Fehlermeldung, wenn das Laden der Beispieldaten fehlschlägt", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "Alle Daten gelöscht", + "@allDataCleared": { + "description": "Erfolgsmeldung nach dem Löschen aller Daten" + }, + "failedToStartBackgroundTracking": "Hintergrund-Tracking konnte nicht gestartet werden. Überprüfen Sie Berechtigungen und BLE-Verbindung.", + "@failedToStartBackgroundTracking": { + "description": "Fehlermeldung, wenn das Hintergrund-Tracking nicht gestartet werden kann" + }, + "locationBroadcast": "Standortübertragung: {latitude}, {longitude}", + "@locationBroadcast": { + "description": "Erfolgsmeldung für Standortübertragung", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "Die Standard-PIN für Geräte ohne Bildschirm ist 123456. Probleme beim Koppeln? Vergessen Sie das Bluetooth-Gerät in den Systemeinstellungen.", + "@defaultPinInfo": { + "description": "Informationen zur Standard-PIN für die Kopplung" + }, + "noMessagesYet": "Noch keine Nachrichten", + "@noMessagesYet": { + "description": "Leerzustandsnachricht, wenn keine Nachrichten vorhanden sind" + }, + "pullDownToSync": "Nach unten ziehen, um Nachrichten zu synchronisieren", + "@pullDownToSync": { + "description": "Anweisung zum Herunterziehen zum Aktualisieren von Nachrichten" + }, + "deleteContact": "Kontakt löschen", + "@deleteContact": { + "description": "Beschriftung der Kontakt-löschen-Aktion" + }, + "delete": "Löschen", + "@delete": { + "description": "Beschriftung der Löschen-Schaltfläche" + }, + "viewOnMap": "Auf Karte anzeigen", + "@viewOnMap": { + "description": "Aktion zum Anzeigen des Kontaktstandorts auf der Karte" + }, + "refresh": "Aktualisieren", + "@refresh": { + "description": "Beschriftung der Aktualisieren-Schaltfläche" + }, + "resetPath": "Pfad zurücksetzen (Umleitung)", + "@resetPath": { + "description": "Aktion zum Zurücksetzen des Kontaktpfads für Umleitung" + }, + "copiedToClipboard": "{label} in die Zwischenablage kopiert", + "@copiedToClipboard": { + "description": "Erfolgsmeldung, wenn ein Wert in die Zwischenablage kopiert wurde", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "Bitte geben Sie ein Passwort ein", + "@pleaseEnterPassword": { + "description": "Validierungsnachricht für leeres Passwortfeld" + }, + "failedToSyncContacts": "Kontaktsynchronisation fehlgeschlagen: {error}", + "@failedToSyncContacts": { + "description": "Fehlermeldung, wenn die Kontaktsynchronisation fehlschlägt", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "Erfolgreich angemeldet! Warte auf Raumnachrichten...", + "@loggedInSuccessfully": { + "description": "Erfolgsmeldung nach erfolgreicher Raumanmeldung" + }, + "loginFailed": "Anmeldung fehlgeschlagen - falsches Passwort", + "@loginFailed": { + "description": "Fehlermeldung, wenn die Raumanmeldung fehlschlägt" + }, + "loggingIn": "Anmeldung bei {roomName}...", + "@loggingIn": { + "description": "Statusmeldung während des Raumanmeldungsvorgangs", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "Anmeldung senden fehlgeschlagen: {error}", + "@failedToSendLogin": { + "description": "Fehlermeldung, wenn das Senden des Anmeldebefehls fehlschlägt", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "Niedrige Standortgenauigkeit", + "@lowLocationAccuracy": { + "description": "Warnungstitel für niedrige GPS-Genauigkeit" + }, + "continue_": "Fortfahren", + "@continue_": { + "description": "Beschriftung der Fortfahren-Schaltfläche" + }, + "sendSarMarker": "SAR-Markierung senden", + "@sendSarMarker": { + "description": "Aktion zum Senden einer SAR-Markierung" + }, + "deleteDrawing": "Zeichnung löschen", + "@deleteDrawing": { + "description": "Aktion zum Löschen einer Kartenzeichnung" + }, + "drawingTools": "Zeichenwerkzeuge", + "@drawingTools": { + "description": "Zeichenwerkzeuge Abschnitt oder Menütitel" + }, + "drawLine": "Linie zeichnen", + "@drawLine": { + "description": "Kartenzeichnungsmodus: Linie" + }, + "drawLineDesc": "Freihandlinie auf der Karte zeichnen", + "@drawLineDesc": { + "description": "Beschreibung für Linienzeichnungsmodus" + }, + "drawRectangle": "Rechteck zeichnen", + "@drawRectangle": { + "description": "Kartenzeichnungsmodus: Rechteck" + }, + "drawRectangleDesc": "Rechteckigen Bereich auf der Karte zeichnen", + "@drawRectangleDesc": { + "description": "Beschreibung für Rechteckzeichnungsmodus" + }, + "measureDistance": "Entfernung messen", + "@measureDistance": { + "description": "Kartenzeichnungsmodus: Entfernung messen" + }, + "measureDistanceDesc": "Zwei Punkte lang drücken zum Messen", + "@measureDistanceDesc": { + "description": "Beschreibung für Entfernungsmessungsmodus" + }, + "clearMeasurement": "Messung löschen", + "@clearMeasurement": { + "description": "Tooltip zum Löschen der Messung" + }, + "distanceLabel": "Entfernung: {distance}", + "@distanceLabel": { + "description": "Beschriftung mit gemessener Entfernung", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "Langer Druck für zweiten Punkt", + "@longPressForSecondPoint": { + "description": "Anweisung wenn erster Messpunkt gesetzt ist" + }, + "longPressToStartMeasurement": "Langer Druck für ersten Punkt", + "@longPressToStartMeasurement": { + "description": "Anweisung zum Starten der Messung" + }, + "longPressToStartNewMeasurement": "Langer Druck für neue Messung", + "@longPressToStartNewMeasurement": { + "description": "Anweisung zum Neustarten der Messung nach Abschluss" + }, + "shareDrawings": "Zeichnungen teilen", + "@shareDrawings": { + "description": "Aktion zum Teilen von Zeichnungen im Netzwerk" + }, + "clearAllDrawings": "Alle Zeichnungen löschen", + "@clearAllDrawings": { + "description": "Aktion zum Löschen aller lokalen Zeichnungen" + }, + "completeLine": "Linie fertigstellen", + "@completeLine": { + "description": "Tooltip zum Abschließen einer Linie" + }, + "broadcastDrawingsToTeam": "{count} Zeichnung{plural} an Team senden", + "@broadcastDrawingsToTeam": { + "description": "Untertitel zeigt, wie viele Zeichnungen gesendet werden", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "Alle {count} Zeichnung{plural} entfernen", + "@removeAllDrawings": { + "description": "Untertitel für Aktion alle Zeichnungen entfernen", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "Alle {count} Zeichnung{plural} von der Karte löschen?", + "@deleteAllDrawingsConfirm": { + "description": "Bestätigungsdialog zum Löschen aller Zeichnungen", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "Zeichnung", + "@drawing": { + "description": "Allgemeine Zeichnungsbezeichnung" + }, + "shareDrawingsCount": "{count} Zeichnung{plural} teilen", + "@shareDrawingsCount": { + "description": "Titel für Dialog Zeichnungen teilen", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "Empfangene Zeichnungen anzeigen", + "@showReceivedDrawings": { + "description": "Umschalter zum Ein-/Ausblenden empfangener Zeichnungen von anderen Teammitgliedern" + }, + "showingAllDrawings": "Alle Zeichnungen werden angezeigt", + "@showingAllDrawings": { + "description": "Untertitel, wenn empfangene Zeichnungen sichtbar sind" + }, + "showingOnlyYourDrawings": "Nur Ihre Zeichnungen werden angezeigt", + "@showingOnlyYourDrawings": { + "description": "Untertitel, wenn empfangene Zeichnungen ausgeblendet sind" + }, + "showSarMarkers": "SAR-Markierungen anzeigen", + "@showSarMarkers": { + "description": "Umschalter zum Ein-/Ausblenden von SAR-Markierungen auf der Karte" + }, + "showingSarMarkers": "SAR-Markierungen werden angezeigt", + "@showingSarMarkers": { + "description": "Untertitel, wenn SAR-Markierungen sichtbar sind" + }, + "hidingSarMarkers": "SAR-Markierungen ausgeblendet", + "@hidingSarMarkers": { + "description": "Untertitel, wenn SAR-Markierungen ausgeblendet sind" + }, + "clearAll": "Alle löschen", + "@clearAll": { + "description": "Beschriftung der Alle-löschen-Schaltfläche" + }, + "publicChannel": "Öffentlicher Kanal", + "@publicChannel": { + "description": "Option für öffentlichen Kanal zum Teilen" + }, + "broadcastToAll": "An alle Knoten in der Nähe senden (temporär)", + "@broadcastToAll": { + "description": "Beschreibung für öffentliche Kanalübertragung" + }, + "storedPermanently": "Dauerhaft im Raum gespeichert", + "@storedPermanently": { + "description": "Beschreibung für dauerhafte Raumspeicherung" + }, + "notConnectedToDevice": "Nicht mit Gerät verbunden", + "@notConnectedToDevice": { + "description": "Fehlermeldung, wenn das Gerät nicht für Direktnachrichten verbunden ist" + }, + "typeYourMessage": "Geben Sie Ihre Nachricht ein...", + "@typeYourMessage": { + "description": "Platzhaltertext für Nachrichteneingabefeld" + }, + "quickLocationMarker": "Schnelle Standortmarkierung", + "@quickLocationMarker": { + "description": "Untertitel für SAR-Markierungsblatt-Kopfzeile" + }, + "markerType": "Markierungstyp", + "@markerType": { + "description": "Beschriftung für Markierungstypauswahlbereich" + }, + "sendTo": "Senden an", + "@sendTo": { + "description": "Beschriftung für Zielauswahlbereich" + }, + "noDestinationsAvailable": "Keine Ziele verfügbar.", + "@noDestinationsAvailable": { + "description": "Warnung, wenn keine Räume oder Kanäle vorhanden sind" + }, + "selectDestination": "Ziel auswählen...", + "@selectDestination": { + "description": "Platzhalter für Ziel-Dropdown" + }, + "ephemeralBroadcastInfo": "Temporär: Nur Over-the-Air-Übertragung. Nicht gespeichert - Knoten müssen online sein.", + "@ephemeralBroadcastInfo": { + "description": "Informationen über temporäre Kanalübertragungen" + }, + "persistentRoomInfo": "Dauerhaft: Unveränderlich im Raum gespeichert. Automatisch synchronisiert und offline gespeichert.", + "@persistentRoomInfo": { + "description": "Informationen über dauerhafte Raumspeicherung" + }, + "location": "Standort", + "@location": { + "description": "Beschriftung für Standortbereich" + }, + "fromMap": "Von Karte", + "@fromMap": { + "description": "Badge, das anzeigt, dass der Standort vom Kartentippen stammt" + }, + "gettingLocation": "Standort wird abgerufen...", + "@gettingLocation": { + "description": "Ladenachricht beim Abrufen der GPS-Position" + }, + "locationError": "Standortfehler", + "@locationError": { + "description": "Titel für Standortfehlermeldungen" + }, + "retry": "Wiederholen", + "@retry": { + "description": "Beschriftung der Wiederholen-Schaltfläche" + }, + "refreshLocation": "Standort aktualisieren", + "@refreshLocation": { + "description": "Tooltip für Standort-aktualisieren-Schaltfläche" + }, + "accuracyMeters": "Genauigkeit: ±{accuracy}m", + "@accuracyMeters": { + "description": "Anzeige der GPS-Genauigkeit in Metern", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "Notizen (optional)", + "@notesOptional": { + "description": "Beschriftung für optionales Notizenfeld" + }, + "addAdditionalInformation": "Zusätzliche Informationen hinzufügen...", + "@addAdditionalInformation": { + "description": "Platzhalter für Notizenfeld" + }, + "lowAccuracyWarning": "Standortgenauigkeit beträgt ±{accuracy}m. Dies ist möglicherweise nicht genau genug für SAR-Operationen.\n\nTrotzdem fortfahren?", + "@lowAccuracyWarning": { + "description": "Warnungsdialoginhalt für niedrige GPS-Genauigkeit", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "Bei Raum anmelden", + "@loginToRoom": { + "description": "Titel für Raumanmeldedialog" + }, + "enterPasswordInfo": "Geben Sie das Passwort ein, um auf diesen Raum zuzugreifen. Das Passwort wird für die zukünftige Verwendung gespeichert.", + "@enterPasswordInfo": { + "description": "Informationen zum Raumpasswort" + }, + "password": "Passwort", + "@password": { + "description": "Beschriftung des Passwortfelds" + }, + "enterRoomPassword": "Raumpasswort eingeben", + "@enterRoomPassword": { + "description": "Passwortfeld-Hinweis" + }, + "loggingInDots": "Anmeldung läuft...", + "@loggingInDots": { + "description": "Schaltflächentext während der Anmeldung" + }, + "login": "Anmelden", + "@login": { + "description": "Beschriftung der Anmelden-Schaltfläche" + }, + "failedToAddRoom": "Fehler beim Hinzufügen des Raums zum Gerät: {error}\n\nDer Raum hat möglicherweise noch nicht gesendet.\nVersuchen Sie zu warten, bis der Raum sendet.", + "@failedToAddRoom": { + "description": "Fehlermeldung, wenn das Hinzufügen des Raums fehlschlägt", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "Direkt", + "@direct": { + "description": "Direkter Routing-Indikator" + }, + "flood": "Flut", + "@flood": { + "description": "Flut-Routing-Indikator" + }, + "loggedIn": "Angemeldet", + "@loggedIn": { + "description": "Angemeldet-Status-Badge" + }, + "noGpsData": "Keine GPS-Daten", + "@noGpsData": { + "description": "Nachricht, wenn GPS-Daten nicht verfügbar sind" + }, + "distance": "Entfernung", + "@distance": { + "description": "Beschriftung der Entfernung" + }, + "directPingTimeout": "Direkter Ping-Timeout - wiederhole {name} mit Flutung...", + "@directPingTimeout": { + "description": "Warnung, wenn direkter Ping ein Timeout hat", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "Ping fehlgeschlagen an {name} - keine Antwort erhalten", + "@pingFailed": { + "description": "Fehlermeldung, wenn Ping fehlschlägt", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?\n\nDies entfernt den Kontakt sowohl aus der App als auch vom Begleitfunkgerät.", + "@deleteContactConfirmation": { + "description": "Bestätigungsnachricht zum Löschen eines Kontakts", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "Fehler beim Entfernen des Kontakts: {error}", + "@failedToRemoveContact": { + "description": "Fehlermeldung, wenn das Entfernen des Kontakts fehlschlägt", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "Typ", + "@type": { + "description": "Beschriftung des Kontakttyps" + }, + "publicKey": "Öffentlicher Schlüssel", + "@publicKey": { + "description": "Beschriftung des öffentlichen Schlüssels" + }, + "lastSeen": "Zuletzt gesehen", + "@lastSeen": { + "description": "Beschriftung Zuletzt gesehen" + }, + "yes": "Ja", + "@yes": { + "description": "Ja-Antwort" + }, + "no": "Nein", + "@no": { + "description": "Nein-Antwort" + }, + "permissions": "Berechtigungen", + "@permissions": { + "description": "Beschriftung der Berechtigungen" + }, + "telemetry": "Telemetrie", + "@telemetry": { + "description": "Überschrift des Telemetrie-Bereichs" + }, + "voltage": "Spannung", + "@voltage": { + "description": "Beschriftung der Spannung" + }, + "battery": "Batterie", + "@battery": { + "description": "Beschriftung der Batterie" + }, + "temperature": "Temperatur", + "@temperature": { + "description": "Beschriftung der Temperatur" + }, + "humidity": "Luftfeuchtigkeit", + "@humidity": { + "description": "Beschriftung der Luftfeuchtigkeit" + }, + "pressure": "Druck", + "@pressure": { + "description": "Beschriftung des Drucks" + }, + "gpsTelemetry": "GPS (Telemetrie)", + "@gpsTelemetry": { + "description": "GPS aus Telemetrie-Beschriftung" + }, + "reLoginToRoom": "Erneut bei Raum anmelden", + "@reLoginToRoom": { + "description": "Schaltfläche zur erneuten Anmeldung beim Raum" + }, + "heading": "Kurs", + "@heading": { + "description": "Beschriftung des Kompasskurses" + }, + "elevation": "Höhe", + "@elevation": { + "description": "Beschriftung der Höhe/Höhenlage" + }, + "accuracy": "Genauigkeit", + "@accuracy": { + "description": "Beschriftung der GPS-Genauigkeit" + }, + "bearing": "Peilung", + "@bearing": { + "description": "Peilungsbeschriftung im Kompass" + }, + "direction": "Richtung", + "@direction": { + "description": "Richtungsbeschriftung im Kompass" + }, + "filterMarkers": "Markierungen filtern", + "@filterMarkers": { + "description": "Titel für Markierungen-filtern-Dialog" + }, + "filterMarkersTooltip": "Markierungen filtern", + "@filterMarkersTooltip": { + "description": "Tooltip für Filter-Schaltfläche" + }, + "contactsFilter": "Kontakte", + "@contactsFilter": { + "description": "Filteroption für Kontakte" + }, + "repeatersFilter": "Repeater", + "@repeatersFilter": { + "description": "Filteroption für Repeater" + }, + "sarMarkers": "SAR-Markierungen", + "@sarMarkers": { + "description": "Überschrift des SAR-Markierungen-Bereichs" + }, + "foundPerson": "Person gefunden", + "@foundPerson": { + "description": "Typ der SAR-Markierung: Person gefunden" + }, + "fire": "Feuer", + "@fire": { + "description": "Typ der SAR-Markierung: Feuer" + }, + "stagingArea": "Sammelpunkt", + "@stagingArea": { + "description": "Typ der SAR-Markierung: Sammelpunkt" + }, + "showAll": "Alle anzeigen", + "@showAll": { + "description": "Schaltfläche zum Anzeigen aller Filter" + }, + "locationUnavailable": "Standort nicht verfügbar", + "@locationUnavailable": { + "description": "Nachricht, wenn GPS-Standort nicht verfügbar ist" + }, + "ahead": "voraus", + "@ahead": { + "description": "Relative Peilungsrichtung - voraus" + }, + "degreesRight": "{degrees}° rechts", + "@degreesRight": { + "description": "Relative Peilungsrichtung - rechts", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "{degrees}° links", + "@degreesLeft": { + "description": "Relative Peilungsrichtung - links", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "Breite: {latitude} Lange: {longitude}", + "@latLonFormat": { + "description": "Anzeigenformat für Breiten- und Längengrad", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "Noch keine Kontakte", + "@noContactsYet": { + "description": "Leerzustandsnachricht, wenn keine Kontakte vorhanden sind" + }, + "connectToDeviceToLoadContacts": "Mit einem Gerät verbinden, um Kontakte zu laden", + "@connectToDeviceToLoadContacts": { + "description": "Anweisung zum Verbinden des Geräts zum Laden von Kontakten" + }, + "teamMembers": "Teammitglieder", + "@teamMembers": { + "description": "Überschrift für Teammitglieder (Chat-Kontakte)" + }, + "repeaters": "Repeater", + "@repeaters": { + "description": "Überschrift für Repeater-Knoten" + }, + "rooms": "Räume", + "@rooms": { + "description": "Überschrift für Räume" + }, + "channels": "Kanäle", + "@channels": { + "description": "Überschrift für Broadcast-Kanäle" + }, + "selectMapLayer": "Kartenebene auswählen", + "@selectMapLayer": { + "description": "Titel für Kartenebenen-Auswahldialog" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "Name der OpenStreetMap-Ebene" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "Name der OpenTopoMap-Ebene" + }, + "esriSatellite": "ESRI-Satellit", + "@esriSatellite": { + "description": "Name der ESRI-Satellitenbildebene" + }, + "googleHybrid": "Google Hybrid", + "@googleHybrid": { + "description": "Name der Google Hybrid-Ebene (Satellit + Beschriftungen)" + }, + "googleRoadmap": "Google Straßenkarte", + "@googleRoadmap": { + "description": "Name der Google Straßenkarten-Ebene" + }, + "googleTerrain": "Google Gelände", + "@googleTerrain": { + "description": "Name der Google Gelände-Ebene (topografisch)" + }, + "dragToPosition": "Zur Position ziehen", + "@dragToPosition": { + "description": "Beschriftung beim Ziehen einer Stecknadel auf der Karte" + }, + "createSarMarker": "SAR-Markierung erstellen", + "@createSarMarker": { + "description": "Beschriftung zum Erstellen einer SAR-Markierung von Stecknadel" + }, + "compass": "Kompass", + "@compass": { + "description": "Kompasstitel im detaillierten Kompassdialog" + }, + "navigationAndContacts": "Navigation & Kontakte", + "@navigationAndContacts": { + "description": "Untertitel für Kompassdialog" + }, + "sarAlert": "SAR-ALARM", + "@sarAlert": { + "description": "Beschriftung für SAR-Alarm-Badge auf Nachrichten" + }, + "textCopiedToClipboard": "Text in Zwischenablage kopiert", + "@textCopiedToClipboard": { + "description": "Erfolgsmeldung, wenn Text kopiert wird" + }, + "cannotReplySenderMissing": "Antwort nicht möglich: Absenderinformationen fehlen", + "@cannotReplySenderMissing": { + "description": "Fehler, wenn Absenderinformationen für Antwort fehlen" + }, + "cannotReplyContactNotFound": "Antwort nicht möglich: Kontakt nicht gefunden", + "@cannotReplyContactNotFound": { + "description": "Fehler, wenn Kontakt für Antwort nicht gefunden wird" + }, + "copyText": "Text kopieren", + "deleteMessage": "Nachricht löschen", + "deleteMessageConfirmation": "Möchten Sie diese Nachricht wirklich löschen?", + "shareLocation": "Standort teilen", + "shareLocationText": "{markerInfo}\n\nKoordinaten: {lat}, {lon}\n\nGoogle Maps: {url}", + "sarLocationShare": "SAR Standort", + "justNow": "Gerade eben", + "@justNow": { + "description": "Zeitindikator für sehr aktuelle Aktivität" + }, + "minutesAgo": "vor {minutes}m", + "@minutesAgo": { + "description": "Zeitindikator für Minuten zuvor", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "vor {hours}h", + "@hoursAgo": { + "description": "Zeitindikator für Stunden zuvor", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "vor {days}d", + "@daysAgo": { + "description": "Zeitindikator für Tage zuvor", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "vor {seconds}s", + "@secondsAgo": { + "description": "Zeitindikator für Sekunden zuvor", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "Wird gesendet...", + "@sending": { + "description": "Zustellungsstatus: wird gesendet" + }, + "sent": "Gesendet", + "@sent": { + "description": "Zustellungsstatus: gesendet" + }, + "delivered": "Zugestellt", + "@delivered": { + "description": "Zustellungsstatus: zugestellt" + }, + "deliveredWithTime": "Zugestellt ({time}ms)", + "@deliveredWithTime": { + "description": "Zustellungsstatus mit Rundlaufzeit", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "Fehlgeschlagen", + "@failed": { + "description": "Zustellungsstatus: fehlgeschlagen" + }, + "broadcast": "Rundspruch", + "@broadcast": { + "description": "Zustellstatus für Kanalnachrichten (noch keine Echos)" + }, + "deliveredToContacts": "Zugestellt an {delivered}/{total} Kontakte", + "@deliveredToContacts": { + "description": "Gruppennachricht Zustellungsanzahl", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "Alle zugestellt", + "@allDelivered": { + "description": "Status wenn alle Empfänger die Nachricht erhalten haben" + }, + "recipientDetails": "Empfängerdetails", + "@recipientDetails": { + "description": "Überschrift für erweiterbare Empfängerliste" + }, + "pending": "Ausstehend", + "@pending": { + "description": "Zustellungsstatus: ausstehend/wartend" + }, + "sarMarkerFoundPerson": "Person gefunden", + "@sarMarkerFoundPerson": { + "description": "SAR-Markierungstyp: Person gefunden" + }, + "sarMarkerFire": "Feuerstandort", + "@sarMarkerFire": { + "description": "SAR-Markierungstyp: Feuer" + }, + "sarMarkerStagingArea": "Sammelpunkt", + "@sarMarkerStagingArea": { + "description": "SAR-Markierungstyp: Sammelpunkt" + }, + "sarMarkerObject": "Objekt gefunden", + "@sarMarkerObject": { + "description": "SAR-Markierungstyp: Objekt" + }, + "from": "Von", + "@from": { + "description": "Absender-Beschriftung in Benachrichtigungen" + }, + "coordinates": "Koordinaten", + "@coordinates": { + "description": "Koordinaten-Beschriftung" + }, + "tapToViewOnMap": "Tippen, um auf der Karte anzuzeigen", + "@tapToViewOnMap": { + "description": "Benachrichtigungsaktionstext" + }, + "radioSettings": "Funkeinstellungen", + "@radioSettings": { + "description": "Titel des Abschnitts für Funkeinstellungen" + }, + "frequencyMHz": "Frequenz (MHz)", + "@frequencyMHz": { + "description": "Beschriftung für Funkfrequenzfeld" + }, + "bandwidth": "Bandbreite", + "@bandwidth": { + "description": "Beschriftung für Bandbreiten-Dropdown" + }, + "spreadingFactor": "Spreading-Faktor", + "@spreadingFactor": { + "description": "Beschriftung für Spreading-Faktor-Dropdown" + }, + "codingRate": "Codierungsrate", + "@codingRate": { + "description": "Beschriftung für Codierungsraten-Dropdown" + }, + "txPowerDbm": "TX-Leistung (dBm)", + "@txPowerDbm": { + "description": "Beschriftung für TX-Leistungsfeld" + }, + "maxPowerDbm": "Max.: {power} dBm", + "@maxPowerDbm": { + "description": "Hilfetext zur maximalen TX-Leistung", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "Du", + "@you": { + "description": "Beschriftung für den aktuellen Benutzer in Nachrichtenblasen" + }, + "exportFailed": "Export fehlgeschlagen: {error}", + "@exportFailed": { + "description": "Fehlermeldung bei Export-Fehler", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "Import fehlgeschlagen: {error}", + "@importFailed": { + "description": "Fehlermeldung bei Import-Fehler", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "Unbekannt", + "@unknown": { + "description": "Beschriftung für unbekannten Wert" + }, + "onlineLayers": "Online-Ebenen", + "@onlineLayers": { + "description": "Überschrift für Online-Kartenebenen" + }, + "locationTrail": "Standortverlauf", + "@locationTrail": { + "description": "Titel des Standortverlaufs" + }, + "showTrailOnMap": "Verlauf auf Karte anzeigen", + "@showTrailOnMap": { + "description": "Umschalter zum Ein-/Ausblenden des Verlaufs auf der Karte" + }, + "trailVisible": "Verlauf ist auf der Karte sichtbar", + "@trailVisible": { + "description": "Verlaufssichtbarkeitsstatus - sichtbar" + }, + "trailHiddenRecording": "Verlauf ist ausgeblendet (Aufzeichnung läuft noch)", + "@trailHiddenRecording": { + "description": "Verlaufssichtbarkeitsstatus - ausgeblendet, aber Aufzeichnung läuft" + }, + "duration": "Dauer", + "@duration": { + "description": "Beschriftung der Dauer" + }, + "points": "Punkte", + "@points": { + "description": "Beschriftung der Verlaufspunkte-Anzahl" + }, + "clearTrail": "Verlauf löschen", + "@clearTrail": { + "description": "Schaltfläche zum Löschen des Standortverlaufs" + }, + "clearTrailQuestion": "Verlauf löschen?", + "@clearTrailQuestion": { + "description": "Titel des Bestätigungsdialogs" + }, + "clearTrailConfirmation": "Sind Sie sicher, dass Sie den aktuellen Standortverlauf löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "@clearTrailConfirmation": { + "description": "Nachricht des Bestätigungsdialogs" + }, + "noTrailRecorded": "Noch kein Verlauf aufgezeichnet", + "@noTrailRecorded": { + "description": "Nachricht, wenn kein Verlauf vorhanden ist" + }, + "startTrackingToRecord": "Standort-Tracking starten, um Ihren Verlauf aufzuzeichnen", + "@startTrackingToRecord": { + "description": "Anweisungen zum Starten der Verlaufsaufzeichnung" + }, + "trailControls": "Verlaufssteuerung", + "@trailControls": { + "description": "Tooltip für Verlaufssteuerung" + }, + "model": "Modell", + "@model": { + "description": "Beschriftung des Gerätemodells" + }, + "version": "Version", + "@version": { + "description": "Beschriftung der Version" + }, + "maxContacts": "Max. Kontakte", + "@maxContacts": { + "description": "Beschriftung der maximalen Kontaktkapazität" + }, + "maxChannels": "Max. Kanäle", + "@maxChannels": { + "description": "Beschriftung der maximalen Kanalkapazität" + }, + "publicInfo": "Öffentliche Informationen", + "@publicInfo": { + "description": "Überschrift des öffentlichen Informationen-Bereichs" + }, + "useCurrentLocation": "Aktuellen Standort verwenden", + "@useCurrentLocation": { + "description": "Tooltip für Schaltfläche zum Verwenden des aktuellen Standorts" + }, + "noneUnknown": "Keine/Unbekannt", + "@noneUnknown": { + "description": "Gerätetyp: keine oder unbekannt" + }, + "chatNode": "Chat-Knoten", + "@chatNode": { + "description": "Gerätetyp: Chat-Knoten" + }, + "repeater": "Relais", + "@repeater": { + "description": "Gerätetyp: Repeater" + }, + "roomChannel": "Raum/Kanal", + "@roomChannel": { + "description": "Gerätetyp: Raum oder Kanal" + }, + "typeNumber": "Typ {number}", + "@typeNumber": { + "description": "Generischer Gerätetyp mit Nummer", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "Fehler beim Speichern: {error}", + "@failedToSave": { + "description": "Allgemeine Fehlermeldung für Speicherfehler", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "Fehler beim Abrufen des Standorts: {error}", + "@failedToGetLocation": { + "description": "Fehlermeldung, wenn das Abrufen des Standorts fehlschlägt", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "SAR-Vorlagen", + "addTemplate": "Vorlage hinzufügen", + "editTemplate": "Vorlage bearbeiten", + "deleteTemplate": "Vorlage löschen", + "templateName": "Vorlagenname", + "templateNameHint": "z. B. Gefundene Person", + "templateEmoji": "Emoji", + "emojiRequired": "Emoji ist erforderlich", + "nameRequired": "Name ist erforderlich", + "templateDescription": "Beschreibung (optional)", + "templateDescriptionHint": "Zusatzlichen Kontext hinzufugen...", + "templateColor": "Farbe", + "previewFormat": "Vorschau (SAR-Nachrichtenformat)", + "importFromClipboard": "Importieren", + "exportToClipboard": "Exportieren", + "deleteTemplateConfirmation": "Vorlage '{name}' loschen?", + "templateAdded": "Vorlage hinzugefugt", + "templateUpdated": "Vorlage aktualisiert", + "templateDeleted": "Vorlage geloscht", + "templatesImported": "{count, plural, =0{Keine Vorlagen importiert} =1{1 Vorlage importiert} other{{count} Vorlagen importiert}}", + "templatesExported": "{count, plural, =1{1 Vorlage in die Zwischenablage exportiert} other{{count} Vorlagen in die Zwischenablage exportiert}}", + "resetToDefaults": "Auf Standard zurücksetzen", + "resetToDefaultsConfirmation": "Dadurch werden alle benutzerdefinierten Vorlagen gelöscht und die 4 Standardvorlagen wiederhergestellt. Fortfahren?", + "reset": "Zurücksetzen", + "resetComplete": "Vorlagen auf Standard zurückgesetzt", + "noTemplates": "Keine Vorlagen verfugbar", + "tapAddToCreate": "Tippen Sie auf +, um Ihre erste Vorlage zu erstellen", + "ok": "OK", + "locationPermission": "Standortberechtigung", + "checking": "Überprüfen...", + "locationPermissionGrantedAlways": "Erteilt (Immer)", + "locationPermissionGrantedWhileInUse": "Erteilt (Während der Nutzung)", + "locationPermissionDeniedTapToRequest": "Verweigert - Tippen zum Anfragen", + "locationPermissionPermanentlyDeniedOpenSettings": "Dauerhaft verweigert - Einstellungen öffnen", + "locationPermissionDialogContent": "Die Standortberechtigung wurde dauerhaft verweigert. Bitte aktivieren Sie sie in Ihren Geräteeinstellungen, um GPS-Tracking und Standortfreigabe zu nutzen.", + "openSettings": "Einstellungen öffnen", + "locationPermissionGranted": "Standortberechtigung erteilt!", + "locationPermissionRequiredForGps": "Die Standortberechtigung ist erforderlich für GPS-Tracking und Standortfreigabe.", + "locationPermissionAlreadyGranted": "Die Standortberechtigung wurde bereits erteilt.", + "sarNavyBlue": "SAR Navy Blau", + "sarNavyBlueDescription": "Professionell/Einsatzmodus", + "selectRecipient": "Empfänger auswählen", + "broadcastToAllNearby": "An alle in der Nähe senden", + "searchRecipients": "Empfänger suchen...", + "noContactsFound": "Keine Kontakte gefunden", + "noRoomsFound": "Keine Räume gefunden", + "noRecipientsAvailable": "Keine Empfänger verfügbar", + "noChannelsFound": "Keine Kanäle gefunden", + "newMessage": "Neue Nachricht", + "channel": "Kanal", + "samplePoliceLead": "Polizeiführer", + "sampleDroneOperator": "Drohnenbediener", + "sampleFirefighterAlpha": "Feuerwehrmann", + "sampleMedicCharlie": "Sanitäter", + "sampleCommandDelta": "Kommando", + "sampleFireEngine": "Feuerwehrfahrzeug", + "sampleAirSupport": "Luftunterstützung", + "sampleBaseCoordinator": "Basiskoordinator", + "channelEmergency": "Notfall", + "channelCoordination": "Koordination", + "channelUpdates": "Aktualisierungen", + "sampleTeamMember": "Beispiel-Teammitglied", + "sampleScout": "Beispiel-Späher", + "sampleBase": "Beispiel-Basis", + "sampleSearcher": "Beispiel-Sucher", + "sampleObjectBackpack": " Rucksack gefunden - blaue Farbe", + "sampleObjectVehicle": " Fahrzeug verlassen - Besitzer prüfen", + "sampleObjectCamping": " Campingausrüstung entdeckt", + "sampleObjectTrailMarker": " Wegmarkierung abseits des Pfades gefunden", + "sampleMsgAllTeamsCheckIn": "Alle Teams melden", + "sampleMsgWeatherUpdate": "Wetterupdate: Klarer Himmel, Temp. 18°C", + "sampleMsgBaseCamp": "Basislager am Sammelplatz eingerichtet", + "sampleMsgTeamAlpha": "Team bewegt sich zu Sektor 2", + "sampleMsgRadioCheck": "Funkcheck - alle Stationen antworten", + "sampleMsgWaterSupply": "Wasserversorgung verfügbar an Kontrollpunkt 3", + "sampleMsgTeamBravo": "Team meldet: Sektor 1 frei", + "sampleMsgEtaRallyPoint": "Ankunftszeit am Sammelpunkt: 15 Minuten", + "sampleMsgSupplyDrop": "Versorgungsabwurf bestätigt für 14:00", + "sampleMsgDroneSurvey": "Drohnenüberwachung abgeschlossen - keine Funde", + "sampleMsgTeamCharlie": "Team fordert Unterstützung an", + "sampleMsgRadioDiscipline": "An alle Einheiten: Funkdisziplin wahren", + "sampleMsgUrgentMedical": "DRINGEND: Medizinische Hilfe benötigt in Sektor 4", + "sampleMsgAdultMale": " Erwachsener Mann, bei Bewusstsein", + "sampleMsgFireSpotted": "Feuer gesichtet - Koordinaten folgen", + "sampleMsgSpreadingRapidly": " Breitet sich schnell aus!", + "sampleMsgPriorityHelicopter": "PRIORITÄT: Brauche Hubschrauberunterstützung", + "sampleMsgMedicalTeamEnRoute": "Medizinisches Team auf dem Weg zu Ihrem Standort", + "sampleMsgEvacHelicopter": "Evakuierungshubschrauber ETA 10 Minuten", + "sampleMsgEmergencyResolved": "Notfall behoben - alles klar", + "sampleMsgEmergencyStagingArea": " Notfall-Sammelplatz", + "sampleMsgEmergencyServices": "Rettungsdienste benachrichtigt und auf dem Weg", + "sampleAlphaTeamLead": "Team-Leiter", + "sampleBravoScout": "Späher", + "sampleCharlieMedic": "Sanitäter", + "sampleDeltaNavigator": "Navigator", + "sampleEchoSupport": "Unterstützung", + "sampleBaseCommand": "Basis-Kommando", + "sampleFieldCoordinator": "Feldkoordinator", + "sampleMedicalTeam": "Medizinisches Team", + "mapDrawing": "Kartenzeichnung", + "lineDrawing": "Linie", + "rectangleDrawing": "Rechteck", + "navigateToDrawing": "Zur Zeichnung navigieren", + "hideFromMap": "Von Karte ausblenden", + "copyCoordinates": "Koordinaten kopieren", + "manualCoordinates": "Manuelle Koordinaten", + "enterCoordinatesManually": "Koordinaten manuell eingeben", + "latitudeLabel": "Breitengrad", + "longitudeLabel": "Längengrad", + "exampleCoordinates": "Beispiel: 46.0569, 14.5058", + "shareDrawing": "Zeichnung teilen", + "shareWithAllNearbyDevices": "Mit allen Geräten in der Nähe teilen", + "shareToRoom": "In Raum teilen", + "sendToPersistentStorage": "An persistenten Raum-Speicher senden", + "deleteDrawingConfirm": "Möchten Sie diese Zeichnung wirklich löschen?", + "drawingDeleted": "Zeichnung gelöscht", + "yourDrawingsCount": "Ihre Zeichnungen ({count})", + "shared": "Geteilt", + "line": "Linie", + "rectangle": "Rechteck", + "saveAsTemplate": "Als Vorlage speichern", + "templateSaved": "Vorlage erfolgreich gespeichert", + "templateAlreadyExists": "Vorlage mit diesem Emoji existiert bereits", + "updateAvailable": "Update Verfügbar", + "currentVersion": "Aktuell", + "latestVersion": "Neueste", + "downloadUpdate": "Herunterladen", + "updateLater": "Später", + "cadastralParcels": "Katasterparzellen", + "forestRoads": "Waldwege", + "wmsOverlays": "WMS Überlagerungen", + "hikingTrails": "Wanderwege", + "mainRoads": "Hauptstraßen", + "houseNumbers": "Hausnummern", + "fireHazardZones": "Brandgefährdungszonen", + "historicalFires": "Historische Brände", + "firebreaks": "Brandschneisen", + "krasFireZones": "Kras-Brandzonen", + "placeNames": "Ortsnamen", + "municipalityBorders": "Gemeindegrenzen", + "topographicMap": "Topographische Karte 1:25000", + "recentMessages": "Aktuelle Nachrichten", + "addChannel": "Kanal hinzufügen", + "channelName": "Kanalname", + "channelNameHint": "z.B. Rettungsteam Alpha", + "channelSecret": "Kanal-Passwort", + "channelSecretHint": "Gemeinsames Passwort für diesen Kanal", + "channelSecretHelp": "Dieses Passwort muss mit allen Teammitgliedern geteilt werden, die Zugriff auf diesen Kanal benötigen", + "channelTypesInfo": "Hash-Kanäle (#team): Passwort automatisch aus dem Namen generiert. Gleicher Name = gleicher Kanal auf allen Geräten.\n\nPrivate Kanäle: Verwenden Sie ein explizites Passwort. Nur diejenigen mit dem Passwort können beitreten.", + "hashChannelInfo": "Hash-Kanal: Das Passwort wird automatisch aus dem Kanalnamen generiert. Jeder, der denselben Namen verwendet, wird demselben Kanal beitreten.", + "channelNameRequired": "Kanalname ist erforderlich", + "channelNameTooLong": "Kanalname darf maximal 31 Zeichen lang sein", + "channelSecretRequired": "Kanal-Passwort ist erforderlich", + "channelSecretTooLong": "Kanal-Passwort darf maximal 32 Zeichen lang sein", + "invalidAsciiCharacters": "Nur ASCII-Zeichen sind erlaubt", + "channelCreatedSuccessfully": "Kanal erfolgreich erstellt", + "channelCreationFailed": "Kanal konnte nicht erstellt werden: {error}", + "deleteChannel": "Kanal löschen", + "deleteChannelConfirmation": "Sind Sie sicher, dass Sie den Kanal \"{channelName}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "channelDeletedSuccessfully": "Kanal erfolgreich gelöscht", + "channelDeletionFailed": "Kanal konnte nicht gelöscht werden: {error}", + "createChannel": "Kanal erstellen", + "wizardBack": "Zurück", + "wizardSkip": "Überspringen", + "wizardNext": "Weiter", + "wizardGetStarted": "Loslegen", + "wizardWelcomeTitle": "Willkommen bei MeshCore SAR", + "viewWelcomeTutorial": "Willkommens-Tutorial ansehen", + "allTeamContacts": "Alle Team-Kontakte", + "directMessagesInfo": "Direktnachrichten mit Bestätigungen. An {count} Teammitglieder gesendet.", + "sarMarkerSentToContacts": "SAR-Marker an {count} Kontakte gesendet", + "noContactsAvailable": "Keine Team-Kontakte verfügbar", + "reply": "Antworten", + "technicalDetails": "Technische Details", + "messageTechnicalDetails": "Technische Nachrichtendetails", + "linkQuality": "Verbindungsqualität", + "delivery": "Zustellung", + "status": "Status", + "expectedAckTag": "Erwartetes ACK-Tag", + "roundTrip": "Roundtrip", + "retryAttempt": "Wiederholungsversuch", + "floodFallback": "Flood-Fallback", + "identity": "Identität", + "messageId": "Nachrichten-ID", + "sender": "Absender", + "senderKey": "Absenderschlüssel", + "recipient": "Empfänger", + "recipientKey": "Empfängerschlüssel", + "voice": "Sprachnachricht", + "voiceId": "Sprach-ID", + "envelope": "Umschlag", + "sessionProgress": "Sitzungsfortschritt", + "complete": "Vollständig", + "rawDump": "Rohdaten", + "cannotRetryMissingRecipient": "Wiederholung nicht möglich: Empfängerinformationen fehlen", + "voiceUnavailable": "Sprachnachricht momentan nicht verfügbar", + "requestingVoice": "Sprachnachricht wird angefordert", + "device": "Gerät", + "change": "Ändern", + "wizardOverviewDescription": "Diese App vereint MeshCore-Nachrichten, SAR-Feldupdates, Karten und Gerätetools an einem Ort.", + "wizardOverviewFeature1": "Sende Direkt-, Raum- und Kanalnachrichten aus dem Haupttab \"Nachrichten\".", + "wizardOverviewFeature2": "Teile SAR-Markierungen, Kartenzeichnungen, Sprachclips und Bilder über das Mesh.", + "wizardOverviewFeature3": "Verbinde dich per BLE oder TCP und verwalte dann das Begleitfunkgerät direkt in der App.", + "wizardMessagingTitle": "Nachrichten und Feldberichte", + "wizardMessagingDescription": "Nachrichten sind hier mehr als nur Klartext. Die App unterstützt bereits mehrere operative Datentypen und Übertragungsabläufe.", + "wizardMessagingFeature1": "Sende Direktnachrichten, Raumbeiträge und Kanalverkehr aus einem einzigen Editor.", + "wizardMessagingFeature2": "Erstelle SAR-Updates und wiederverwendbare SAR-Vorlagen für häufige Feldberichte.", + "wizardMessagingFeature3": "Übertrage Sprachsitzungen und Bilder mit Fortschritts- und Airtime-Anzeigen in der Oberfläche.", + "wizardConnectDeviceTitle": "Gerät verbinden", + "wizardConnectDeviceDescription": "Verbinde dein MeshCore-Funkgerät, wähle einen Namen und wende ein Funkprofil an, bevor du fortfährst.", + "wizardSetupBadge": "Einrichtung", + "wizardOverviewBadge": "Überblick", + "wizardConnectedToDevice": "Verbunden mit {deviceName}", + "wizardNoDeviceConnected": "Noch kein Gerät verbunden", + "wizardSkipForNow": "Vorerst überspringen", + "wizardDeviceNameLabel": "Gerätename", + "wizardDeviceNameHelp": "Dieser Name wird an andere MeshCore-Nutzer ausgesendet.", + "wizardConfigRegionLabel": "Konfigurationsregion", + "wizardConfigRegionHelp": "Verwendet die vollständige offizielle Liste der MeshCore-Voreinstellungen. Standard ist EU/UK (Narrow).", + "wizardPresetNote1": "Stelle sicher, dass die ausgewählte Voreinstellung zu deinen lokalen Funkvorschriften passt.", + "wizardPresetNote2": "Die Liste entspricht dem offiziellen Preset-Feed des MeshCore-Konfigurationstools.", + "wizardPresetNote3": "EU/UK (Narrow) bleibt standardmäßig für das Onboarding ausgewählt.", + "wizardSaving": "Wird gespeichert...", + "wizardSaveAndContinue": "Speichern und fortfahren", + "wizardEnterDeviceName": "Gib vor dem Fortfahren einen Gerätenamen ein.", + "wizardDeviceSetupSaved": "{deviceName} mit {presetName} gespeichert.", + "wizardNetworkTitle": "Kontakte, Räume und Repeater", + "wizardNetworkDescription": "Der Kontakte-Tab organisiert das Netzwerk, das du entdeckst, und die Routen, die im Laufe der Zeit gelernt werden.", + "wizardNetworkFeature1": "Prüfe Teammitglieder, Repeater, Räume, Kanäle und ausstehende Adverts in einer Liste.", + "wizardNetworkFeature2": "Nutze Smart Ping, Raum-Login, gelernte Pfade und Pfad-Reset-Tools, wenn die Verbindung unübersichtlich wird.", + "wizardNetworkFeature3": "Erstelle Kanäle und verwalte Netzwerkziele, ohne die App zu verlassen.", + "wizardMapOpsTitle": "Karte, Spuren und geteilte Geometrie", + "wizardMapOpsDescription": "Die Karte der App ist direkt mit Nachrichten, Tracking und SAR-Overlays verbunden, statt nur ein separater Viewer zu sein.", + "wizardMapOpsFeature1": "Verfolge deine eigene Position, Teamstandorte und Bewegungsspuren auf der Karte.", + "wizardMapOpsFeature2": "Öffne Zeichnungen aus Nachrichten, prüfe sie inline und entferne sie bei Bedarf von der Karte.", + "wizardMapOpsFeature3": "Nutze Repeater-Kartenansichten und geteilte Overlays, um die Netzabdeckung im Feld zu verstehen.", + "wizardToolsTitle": "Werkzeuge jenseits von Nachrichten", + "wizardToolsDescription": "Es gibt hier mehr als nur die vier Haupttabs. Die App enthält außerdem Konfiguration, Diagnose und optionale Sensor-Workflows.", + "wizardToolsFeature1": "Öffne die Gerätekonfiguration, um Funkparameter, Telemetrie, Sendeleistung und Begleitdetails zu ändern.", + "wizardToolsFeature2": "Aktiviere den Sensoren-Tab, wenn du überwachte Sensor-Dashboards und schnelle Aktualisierungen möchtest.", + "wizardToolsFeature3": "Nutze Paketprotokolle, Spektrumsuche und Entwicklerdiagnosen zur Fehlersuche im Mesh.", + "contactInSensors": "In Sensoren", + "contactAddToSensors": "Zu Sensoren hinzufügen", + "contactSetPath": "Pfad festlegen", + "contactAddedToSensors": "{contactName} zu Sensoren hinzugefügt", + "contactFailedToClearRoute": "Pfad konnte nicht gelöscht werden: {error}", + "contactRouteCleared": "Pfad gelöscht", + "contactRouteSet": "Pfad gesetzt: {route}", + "contactFailedToSetRoute": "Pfad konnte nicht gesetzt werden: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "ACK-Zeitüberschreitung", + "opcode": "Opcode", + "payload": "Nutzlast", + "hops": "Hops", + "hashSize": "Hash-Größe", + "pathBytes": "Pfad-Bytes", + "selectedPath": "Ausgewählter Pfad", + "estimatedTx": "Geschätzte Sendung", + "senderToReceipt": "Sender bis Empfang", + "receivedCopies": "Empfangene Kopien", + "retryCause": "Wiederholungsursache", + "retryMode": "Wiederholungsmodus", + "retryResult": "Wiederholungsergebnis", + "lastRetry": "Letzter Versuch", + "rxPackets": "RX-Pakete", + "mesh": "Mesh", + "rate": "Rate", + "window": "Fenster", + "posttxDelay": "Sendeverzögerung", + "bandpass": "Bandpass", + "bandpassFilterVoice": "Bandpassfilter Sprache", + "active": "Aktiv", + "addContact": "Kontakt hinzufügen", + "all": "Alle", + "clearAllLabel": "Alle löschen", + "clearFilters": "Filter löschen", + "clearRoute": "Route löschen", + "clearMessages": "Nachrichten löschen", + "clearScale": "Maßstab löschen", + "clearDiscoveries": "Entdeckungen löschen", + "clearOnlineTraceDatabase": "Online-Trace-Datenbank löschen", + "clearAllChannels": "Alle Kanäle löschen", + "clearAllContacts": "Alle Kontakte löschen", + "clearChannels": "Kanäle löschen", + "clearContacts": "Kontakte löschen", + "clearPathOnMaxRetry": "Pfad bei max. Wiederholung löschen", + "create": "Erstellen", + "custom": "Benutzerdefiniert", + "defaultValue": "Standard", + "duplicate": "Duplizieren", + "editName": "Name bearbeiten", + "open": "Öffnen", + "paste": "Einfügen", + "preview": "Vorschau", + "remove": "Entfernen", + "rename": "Umbenennen", + "resolveAll": "Alle auflösen", + "send": "Senden", + "sendAnyway": "Trotzdem senden", + "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", + "trace": "Trace", + "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", + "autoaddRoomServers": "Raumserver automatisch hinzufügen", + "autoaddSensors": "Sensoren automatisch hinzufügen", + "autoaddUsers": "Benutzer automatisch hinzufügen", + "overwriteOldestWhenFull": "Älteste bei vollem Speicher überschreiben", + "storage": "Speicher", + "dangerZone": "Gefahrenbereich", + "profiles": "Profile", + "favourites": "Favoriten", + "sensors": "Sensoren", + "others": "Andere", + "gpsModule": "GPS-Modul", + "liveTraffic": "Live-Verkehr", + "repeatersMap": "Repeater-Karte", + "blePacketLogs": "BLE-Paketprotokolle", + "onlineTraceDatabase": "Online-Trace-Datenbank", + "routePathByteSize": "Pfad-Bytegröße", + "messageNotifications": "Nachrichtenbenachrichtigungen", + "sarAlerts": "SAR-Alarme", + "discoveryNotifications": "Erkennungsbenachrichtigungen", + "updateNotifications": "Updatebenachrichtigungen", + "muteWhileAppIsOpen": "Stumm bei geöffneter App", + "disableContacts": "Kontakte deaktivieren", + "enableSensorsTab": "Sensoren-Tab aktivieren", + "enableProfiles": "Profile aktivieren", + "nearestRepeaterFallback": "Nächster Repeater als Fallback", + "deleteAllStoredMessageHistory": "Gesamten Nachrichtenverlauf löschen", + "messageFontSize": "Nachrichtenschriftgröße", + "rotateMapWithHeading": "Karte mit Richtung drehen", + "showMapDebugInfo": "Karten-Debug-Info anzeigen", + "openMapInFullscreen": "Karte im Vollbild öffnen", + "showSarMarkersLabel": "SAR-Markierungen anzeigen", + "hideRepeatersOnMap": "Repeater auf Karte ausblenden", + "setMapScale": "Kartenmaßstab einstellen", + "customMapScaleSaved": "Benutzerdefinierter Kartenmaßstab gespeichert", + "voiceBitrate": "Sprach-Bitrate", + "voiceCompressor": "Sprachkompressor", + "voiceLimiter": "Sprach-Limiter", + "micAutoGain": "Mikrofon-Automatik", + "echoCancellation": "Echounterdrückung", + "noiseSuppression": "Rauschunterdrückung", + "trimSilenceInVoiceMessages": "Stille in Sprachnachrichten trimmen", + "compressor": "Kompressor", + "limiter": "Limiter", + "autoGain": "Automatische Verstärkung", + "echoCancel": "Echo", + "noiseSuppress": "Rauschen", + "silenceTrim": "Stille", + "maxImageSize": "Maximale Bildgröße", + "imageCompression": "Bildkomprimierung", + "grayscale": "Graustufen", + "ultraMode": "Ultra-Modus", + "fastPrivateGpsUpdates": "Schnelle private GPS-Updates", + "movementThreshold": "Bewegungsschwelle", + "fastGpsMovementThreshold": "Schneller GPS-Bewegungsschwellenwert", + "fastGpsActiveuseInterval": "Schnelles GPS aktive Nutzung Intervall", + "activeuseUpdateInterval": "Aktive Nutzung Updateintervall", + "repeatNearbyTraffic": "Nahverkehr wiederholen", + "relayThroughRepeatersAcrossTheMesh": "Über Repeater im Mesh weiterleiten", + "nearbyOnlyWithoutRepeaterFlooding": "Nur in der Nähe, ohne Repeater-Flooding", + "multihop": "Multi-Hop", + "createProfile": "Profil erstellen", + "renameProfile": "Profil umbenennen", + "newProfile": "Neues Profil", + "manageProfiles": "Profile verwalten", + "enableProfilesToStartManagingThem": "Aktivieren Sie Profile, um sie zu verwalten.", + "openMessage": "Nachricht öffnen", + "jumpToTheRelatedSarMessage": "Zur zugehörigen SAR-Nachricht springen", + "removeSarMarker": "SAR-Markierung entfernen", + "pleaseSelectADestinationToSendSarMarker": "Bitte wählen Sie ein Ziel zum Senden der SAR-Markierung", + "sarMarkerBroadcastToPublicChannel": "SAR-Markierung an öffentlichen Kanal gesendet", + "sarMarkerSentToRoom": "SAR-Markierung an Raum gesendet", + "loadFromGallery": "Aus Galerie laden", + "replaceImage": "Bild ersetzen", + "selectFromGallery": "Aus Galerie auswählen", + "team": "Team", + "found": "Gefunden", + "staging": "Sammelpunkt", + "object": "Objekt", + "quiet": "Ruhig", + "busy": "Beschäftigt", + "searchMessages": "Nachrichten suchen", + "sendImageFromGallery": "Bild aus Galerie senden", + "takePhoto": "Foto aufnehmen", + "allMessages": "Alle Nachrichten", + "sendToPublicChannel": "An öffentlichen Kanal senden?", + "selectMarkerTypeAndDestination": "Markierungstyp und Ziel auswählen", + "noDestinationsAvailableLabel": "Keine Ziele verfügbar", + "image": "Bild", + "format": "Format", + "dimensions": "Abmessungen", + "segments": "Segmente", + "transfers": "Übertragungen", + "downloadedBy": "Heruntergeladen von", + "saveDiscoverySettings": "Erkennungseinstellungen speichern", + "savePublicInfo": "Öffentliche Info speichern", + "saveRadioSettings": "Funkeinstellungen speichern", + "savePath": "Pfad speichern", + "wipeDeviceData": "Gerätedaten löschen", + "wipeDevice": "Gerät löschen", + "destructiveDeviceActions": "Destruktive Geräteaktionen.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Wählen Sie eine Voreinstellung oder passen Sie die Funkeinstellungen an.", + "used": "Belegt", + "total": "Gesamt", + "renameValue": "Wert umbenennen", + "customizeFields": "Felder anpassen", + "livePreview": "Live-Vorschau", + "refreshSchedule": "Aktualisierungsplan", + "noResponse": "Keine Antwort", + "refreshing": "Wird aktualisiert", + "unavailable": "Nicht verfügbar", + "pickARelayOrNodeToWatchInSensors": "Wählen Sie einen Relay oder Knoten zur Beobachtung.", + "publicKeyLabel": "Öffentlicher Schlüssel", + "alreadyInContacts": "Bereits in Kontakten", + "connectToADeviceBeforeAddingContacts": "Verbinden Sie sich mit einem Gerät, bevor Sie Kontakte hinzufügen", + "fromContacts": "Aus Kontakten", + "onlineOnly": "Nur online", + "inBoth": "In beiden", + "source": "Quelle", + "allMessagesCleared": "Alle Nachrichten gelöscht", + "onlineTraceDatabaseCleared": "Online-Trace-Datenbank gelöscht", + "packetLogsCleared": "Paketprotokolle gelöscht", + "hexDataCopiedToClipboard": "Hex-Daten in die Zwischenablage kopiert", + "developerModeEnabled": "Entwicklermodus aktiviert", + "developerModeDisabled": "Entwicklermodus deaktiviert", + "clipboardIsEmpty": "Zwischenablage ist leer", + "contactImported": "Kontakt importiert", + "contactLinkCopiedToClipboard": "Kontaktlink in Zwischenablage kopiert", + "failedToExportContact": "Kontaktexport fehlgeschlagen", + "noLogsToExport": "Keine Protokolle zum Exportieren", + "exportAsCsv": "Als CSV exportieren", + "exportAsText": "Als Text exportieren", + "receivedRfc3339": "Empfangen (RFC3339)", + "buildTime": "Build-Zeit", + "downloadUrlNotAvailable": "Download-URL nicht verfügbar", + "cannotOpenDownloadUrl": "Download-URL kann nicht geöffnet werden", + "updateCheckIsOnlyAvailableOnAndroid": "Update-Prüfung nur auf Android verfügbar", + "youAreRunningTheLatestVersion": "Sie verwenden die neueste Version", + "updateAvailableButDownloadUrlNotFound": "Update verfügbar, aber Download-URL nicht gefunden", + "startTictactoe": "Tic-Tac-Toe starten", + "tictactoeUnavailable": "Tic-Tac-Toe nicht verfügbar", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: Gegner unbekannt", + "tictactoeWaitingForStart": "Tic-Tac-Toe: Warten auf Start", + "acceptsShareLinks": "Akzeptiert freigegebene Links", + "supportsRawHex": "Unterstützt rohes Hex", + "clipboardfriendly": "Zwischenablage-freundlich", + "captured": "Erfasst", + "size": "Größe", + "noCustomChannelsToClear": "Keine benutzerdefinierten Kanäle zum Löschen.", + "noDeviceContactsToClear": "Keine Gerätekontakte zum Löschen.", + "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", + "simpleMode": "Einfacher Modus", + "simpleModeDescription": "Nur Chat und Karte mit favorisierten Kontakten", + "simpleModeRequiresLockedDestination": "Aktivieren Sie zuerst „Ziel sperren“ mit einem Kanal, um den einfachen Modus zu nutzen", + "noFavouriteContacts": "Keine favorisierten Kontakte", + "selectDestinationFirst": "Wählen Sie zuerst einen Kanal, Kontakt oder Raum", + "recipientNoLongerAvailable": "Empfänger ist nicht mehr verfügbar", + "failedToSendMessage": "Senden fehlgeschlagen: {error}", + "tictactoeDirectOnly": "Tic-Tac-Toe funktioniert nur in Direktnachrichten. Wählen Sie zuerst einen Kontakt.", + "deviceKeyUnavailable": "Geräteschlüssel ist nicht verfügbar", + "imageCompressionFailed": "Bildkomprimierung fehlgeschlagen", + "imageFragmentationFailed": "Bildfragmentierung fehlgeschlagen", + "failedToAnnounceImage": "Bild konnte nicht angekündigt werden", + "noRecipientSelected": "Kein Empfänger ausgewählt", + "imageSendFailed": "Bildversand fehlgeschlagen", + "microphonePermissionRequiredForVoice": "Für Sprachnachrichten ist die Mikrofonberechtigung erforderlich", + "recordingDiscarded": "Aufnahme verworfen", + "publicChannelMediaWarning": "Sie sind dabei, {mediaType} an den öffentlichen Kanal zu senden. Das ist nicht ratsam, da alle im Mesh-Netz es empfangen können. Wählen Sie einen privaten oder getaggten Kanal, sofern dies nicht beabsichtigt ist.", + "selectPrivateChannelToShareLocation": "Wählen Sie einen privaten Kanal, um Ihren Standort zu teilen.", + "stopSharingMyLocation": "Meinen Standort nicht mehr teilen", + "shareMyLocation": "Meinen Standort teilen", + "stopRecording": "Aufnahme stoppen", + "recordVoice": "Sprachnachricht aufnehmen", + "moreActions": "Weitere Aktionen", + "noMessagesInFilter": "Keine Nachrichten in diesem Filter", + "noMatchesInFilter": "Keine Treffer in diesem Filter", + "syncFailed": "Synchronisierung fehlgeschlagen: {error}", + "invalidLink": "Ungültiger Link", + "cannotOpenLink": "Link kann nicht geöffnet werden", + "failedToOpenLink": "Link konnte nicht geöffnet werden", + "failedToResendMessage": "Nachricht konnte nicht erneut gesendet werden", + "retryFailed": "Wiederholung fehlgeschlagen: {error}", + "deleteDrawingAndMessage": "Zeichnung & Nachricht löschen", + "lastEchoRelay": "Letztes Echo – Relais", + "lastEchoPath": "Letztes Echo – Pfad", + "lastEchoBytesReport": "Letztes Echo – Byte-Bericht", + "noGpsCoordinatesAvailable": "Keine GPS-Koordinaten verfügbar", + "notASarMarker": "Keine SAR-Markierung", + "failedToSaveTemplate": "Vorlage konnte nicht gespeichert werden: {error}", + "drawingNotFound": "Zeichnung nicht gefunden", + "unknownDrawingType": "Unbekannter Zeichnungstyp", + "customMapMarker": "Eigene Kartenmarkierung", + "customMapPointLabel": "Punkt: {point}", + "mapIdLabel": "Karten-ID: {id}", + "cannotFetchVoice": "Sprachnachricht kann nicht abgerufen werden", + "senderContactUnknown": "Absenderkontakt ist unbekannt. Synchronisieren Sie zuerst die Kontakte.", + "senderRouteUnknown": "Absenderroute ist unbekannt. Synchronisieren Sie zuerst Kontakte/Pfad.", + "messageTooFar": "Nachricht ist zu weit entfernt ({hops} Hops, max. {maxHops}).", + "senderRouteNoPathResponse": "Absenderroute hat auf die Pfadprüfung nicht reagiert. Synchronisieren Sie Kontakte/Pfad und versuchen Sie es erneut.", + "senderRouteNoRawResponse": "Absenderroute hat auf dem Raw-Transportpfad nicht reagiert.", + "voiceFetchOverHops": "Sprachabruf über {hops} Hops kann eine Weile dauern.", + "voiceReceiveCanceled": "Sprachempfang abgebrochen", + "fetchingMissingVoiceFragments": "Fehlende Sprachfragmente werden abgerufen", + "receivingVoice": "Sprachnachricht wird empfangen", + "cannotFetchImage": "Bild kann nicht abgerufen werden", + "imageFetchOverHops": "Bildabruf über {hops} Hops kann eine Weile dauern.", + "imageAlreadyBeingReceived": "Bild wird bereits empfangen", + "loadImage": "Bild laden", + "imageFetchFailedToSendRequest": "Bildabruf-Anfrage konnte nicht gesendet werden", + "imageUnavailable": "Bild derzeit nicht verfügbar", + "imageFetchTimedOut": "Bildabruf hat das Zeitlimit überschritten", + "imageReceiveCanceled": "Bildempfang abgebrochen", + "closeImagePreview": "Bildvorschau schließen", + "recordingReleaseToSend": "Aufnahme läuft ... loslassen zum Senden", + "sendingVoice": "Sprachnachricht wird gesendet ...", + "sendLongPressToRecordVoice": "Senden (lange drücken für Sprachaufnahme)", + "discardRecording": "Aufnahme verwerfen", + "hopCount": "{count, plural, =1{1 Hop} other{{count} Hops}}", + "channelsSectionDescription": "Broadcast-Kanäle für Mesh-Verkehr in der Nähe", + "roomsSectionDescription": "Gemeinsame Räume für laufende Teamkoordination", + "contactsSectionDescription": "Personen und Geräte, die Sie direkt erreichen können", + "noRecentChatters": "Keine kürzlichen Teilnehmer", + "public": "Öffentlich", + "failedToSendTicTacToeMove": "Tic-Tac-Toe-Zug konnte nicht gesendet werden", + "youWon": "Sie haben gewonnen", + "opponentWon": "Gegner hat gewonnen", + "gameDraw": "Unentschieden", + "yourTurn": "Sie sind am Zug", + "opponentTurn": "Gegner ist am Zug", + "pleaseEnterValidCoordinates": "Bitte geben Sie gültige Koordinaten ein", + "locationNotAvailable": "Standort nicht verfügbar", + "customCaveMapPoint": "Eigener Höhlenkarten-Punkt", + "mapPoint": "Kartenpunkt", + "fetchingMissingImageFragments": "Fehlende Fragmente werden abgerufen …", + "loadingImage": "Wird geladen …", + "receivingImage": "Wird empfangen …", + "tapToLoad": "Zum Laden tippen", + "connectDevice": "Gerät verbinden", + "chooseTransportSubtitle": "Wählen Sie Bluetooth, WiFi oder Seriell als Verbindung", + "ble": "BLE", + "network": "Netzwerk", + "serial": "Seriell", + "scan": "Scannen", + "pressScanToSearchForDevices": "Drücken Sie auf Scannen, um nach Geräten in der Nähe zu suchen", + "failedToConnectToDevice": "Verbindung mit {name} fehlgeschlagen", + "unknownDevice": "Unbekanntes Gerät", + "showingCachedResultsTapRefresh": "Zwischengespeicherte Ergebnisse werden angezeigt. Tippen Sie auf Aktualisieren, um erneut zu scannen.", + "scanningLocalNetworkOnPort": "Lokales Netzwerk wird nach MeshCore-WiFi-Geräten auf Port {port} durchsucht", + "cancelScanAndAddServer": "Scan abbrechen und Server hinzufügen", + "addServer": "Server hinzufügen", + "scanningProgressIps": "Scannen... {scanned}/{total} IPs", + "cancelAndEnterManually": "Abbrechen und manuell eingeben", + "noRecentOrDiscoveredServers": "Noch keine kürzlich verwendeten oder gefundenen Server", + "recentlyUsed": "Kürzlich verwendet", + "connecting": "Verbindung wird hergestellt...", + "discoveredOnThisNetwork": "In diesem Netzwerk gefunden", + "serverNoLongerAvailable": "Server unter {host}:{port} ist nicht mehr verfügbar. Bitte scannen Sie erneut, um aktive Server zu finden.", + "failedToConnectToHost": "Verbindung mit {host}:{port} fehlgeschlagen", + "enterValidIpAddress": "Geben Sie eine gültige IP-Adresse ein", + "enterValidTcpPort": "Geben Sie einen gültigen TCP-Port ein", + "ipAddress": "IP-Adresse", + "tcpPort": "TCP-Port", + "customServerPort": "Benutzerdefinierter Server-Port", + "chat": "Chat", + "restoringPreviousLink": "Vorherige Verbindung wird wiederhergestellt", + "noDeviceConnected": "Kein Gerät verbunden", + "sendAdvert": "Meinen Kontakt senden", + "advertMode": "Meinen Kontakt senden", + "advertModeSubtitle": "Wählen Sie, wie weit Ihre Kontaktdaten übertragen werden sollen.", + "floodAdvertSent": "Kontakt über das Mesh-Netzwerk gesendet", + "directAdvertSent": "Kontakt an Geräte in der Nähe gesendet" +} diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb new file mode 100644 index 0000000..94bec4d --- /dev/null +++ b/lib/l10n/app_el.arb @@ -0,0 +1,2592 @@ +{ + "@@locale": "el", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "The application title" + }, + "messages": "Μηνύματα", + "@messages": { + "description": "Messages tab label" + }, + "contacts": "Επαφές", + "@contacts": { + "description": "Contacts tab label" + }, + "map": "Χάρτης", + "@map": { + "description": "Map tab label" + }, + "settings": "Ρυθμίσεις", + "@settings": { + "description": "Settings screen title" + }, + "connect": "Σύνδεση", + "@connect": { + "description": "Connect button label" + }, + "disconnect": "Αποσύνδεση", + "@disconnect": { + "description": "Disconnect button label" + }, + "noDevicesFound": "Δεν βρέθηκαν συσκευές", + "@noDevicesFound": { + "description": "Text shown when no BLE devices are found" + }, + "scanAgain": "Σάρωση ξανά", + "@scanAgain": { + "description": "Button to restart BLE scanning" + }, + "deviceNotConnected": "Η συσκευή δεν είναι συνδεδεμένη", + "@deviceNotConnected": { + "description": "Error message when device is not connected" + }, + "locationPermissionDenied": "Η άδεια τοποθεσίας απορρίφθηκε", + "@locationPermissionDenied": { + "description": "Error when location permission is denied" + }, + "locationPermissionPermanentlyDenied": "Η άδεια τοποθεσίας απορρίφθηκε μόνιμα. Ενεργοποιήστε την στις Ρυθμίσεις.", + "@locationPermissionPermanentlyDenied": { + "description": "Error when location permission is permanently denied" + }, + "locationPermissionRequired": "Απαιτείται άδεια τοποθεσίας για παρακολούθηση GPS και συντονισμό ομάδας. Μπορείτε να την ενεργοποιήσετε αργότερα στις Ρυθμίσεις.", + "@locationPermissionRequired": { + "description": "Message when location permission is needed" + }, + "locationServicesDisabled": "Οι υπηρεσίες τοποθεσίας είναι απενεργοποιημένες. Ενεργοποιήστε τις στις Ρυθμίσεις.", + "@locationServicesDisabled": { + "description": "Error when location services are disabled" + }, + "failedToGetGpsLocation": "Αποτυχία λήψης τοποθεσίας GPS", + "@failedToGetGpsLocation": { + "description": "Error when GPS location cannot be obtained" + }, + "failedToAdvertise": "Αποτυχία μετάδοσης: {error}", + "@failedToAdvertise": { + "description": "Error message for failed advertisement", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "Ακύρωση επανασύνδεσης", + "@cancelReconnection": { + "description": "Tooltip for cancel reconnection button" + }, + "general": "Γενικά", + "@general": { + "description": "General settings section header" + }, + "theme": "Θέμα", + "@theme": { + "description": "Theme setting label" + }, + "chooseTheme": "Επιλογή θέματος", + "@chooseTheme": { + "description": "Theme selection dialog title" + }, + "light": "Φωτεινό", + "@light": { + "description": "Light theme option" + }, + "dark": "Σκούρο", + "@dark": { + "description": "Dark theme option" + }, + "blueLightTheme": "Μπλε φωτεινό θέμα", + "@blueLightTheme": { + "description": "Description for blue light theme" + }, + "blueDarkTheme": "Μπλε σκούρο θέμα", + "@blueDarkTheme": { + "description": "Description for blue dark theme" + }, + "sarRed": "SAR Κόκκινο", + "@sarRed": { + "description": "SAR Red theme option" + }, + "alertEmergencyMode": "Λειτουργία συναγερμού/έκτακτης ανάγκης", + "@alertEmergencyMode": { + "description": "Description for SAR Red theme" + }, + "sarGreen": "SAR Πράσινο", + "@sarGreen": { + "description": "SAR Green theme option" + }, + "safeAllClearMode": "Λειτουργία ασφαλείας/όλα καθαρά", + "@safeAllClearMode": { + "description": "Description for SAR Green theme" + }, + "autoSystem": "Αυτόματο (σύστημα)", + "@autoSystem": { + "description": "Auto/System theme option" + }, + "followSystemTheme": "Ακολουθεί το θέμα συστήματος", + "@followSystemTheme": { + "description": "Description for system theme" + }, + "showRxTxIndicators": "Εμφάνιση ενδείξεων RX/TX", + "@showRxTxIndicators": { + "description": "Setting to show RX/TX indicators" + }, + "disableMap": "Απενεργοποίηση χάρτη", + "@disableMap": { + "description": "Setting to disable the map tab" + }, + "language": "Γλώσσα", + "@language": { + "description": "Language setting label" + }, + "chooseLanguage": "Επιλογή γλώσσας", + "@chooseLanguage": { + "description": "Language selection dialog title" + }, + "save": "Αποθήκευση", + "@save": { + "description": "Save button label" + }, + "cancel": "Ακύρωση", + "@cancel": { + "description": "Cancel button label" + }, + "close": "Κλείσιμο", + "@close": { + "description": "Close button label" + }, + "about": "Σχετικά", + "@about": { + "description": "About section header" + }, + "appVersion": "Έκδοση εφαρμογής", + "@appVersion": { + "description": "App version label" + }, + "aboutMeshCoreSar": "Σχετικά με το MeshCore SAR", + "@aboutMeshCoreSar": { + "description": "About dialog title" + }, + "aboutDescription": "Εφαρμογή Έρευνας και Διάσωσης σχεδιασμένη για ομάδες απόκρισης έκτακτης ανάγκης. Περιλαμβάνει:\n\n• Δικτύωση BLE mesh για επικοινωνία συσκευή με συσκευή\n• Χάρτες εκτός σύνδεσης με πολλαπλά επίπεδα\n• Παρακολούθηση μελών ομάδας σε πραγματικό χρόνο\n• Τακτικούς δείκτες SAR (εντοπισμένο άτομο, φωτιά, σημείο συγκέντρωσης)\n• Διαχείριση επαφών και μηνυμάτων\n• Παρακολούθηση GPS με πυξίδα κατεύθυνσης\n• Cache πλακιδίων χάρτη για χρήση εκτός σύνδεσης", + "@aboutDescription": { + "description": "About dialog description" + }, + "technologiesUsed": "Χρησιμοποιούμενες τεχνολογίες:", + "@technologiesUsed": { + "description": "Technologies used section title" + }, + "technologiesList": "• Flutter για ανάπτυξη πολλαπλών πλατφορμών\n• BLE (Bluetooth Low Energy) για δικτύωση mesh\n• OpenStreetMap για χαρτογράφηση\n• Provider για διαχείριση κατάστασης\n• SharedPreferences για τοπική αποθήκευση", + "@technologiesList": { + "description": "List of technologies used" + }, + "moreInfo": "Περισσότερες πληροφορίες", + "@moreInfo": { + "description": "More info button label" + }, + "packageName": "Όνομα πακέτου", + "@packageName": { + "description": "Package name label" + }, + "sampleData": "Δείγματα δεδομένων", + "@sampleData": { + "description": "Sample data section header" + }, + "sampleDataDescription": "Φόρτωση ή εκκαθάριση δοκιμαστικών επαφών, μηνυμάτων καναλιού και δεικτών SAR για έλεγχο", + "@sampleDataDescription": { + "description": "Sample data section description" + }, + "loadSampleData": "Φόρτωση δειγμάτων δεδομένων", + "@loadSampleData": { + "description": "Load sample data button" + }, + "clearAllData": "Εκκαθάριση όλων των δεδομένων", + "@clearAllData": { + "description": "Clear all data button" + }, + "clearAllDataConfirmTitle": "Εκκαθάριση όλων των δεδομένων", + "@clearAllDataConfirmTitle": { + "description": "Clear data confirmation dialog title" + }, + "clearAllDataConfirmMessage": "Αυτό θα διαγράψει όλες τις επαφές και τους δείκτες SAR. Είστε βέβαιοι;", + "@clearAllDataConfirmMessage": { + "description": "Clear data confirmation message" + }, + "clear": "Εκκαθάριση", + "@clear": { + "description": "Clear button label" + }, + "loadedSampleData": "Φορτώθηκαν {teamCount} μέλη ομάδας, {channelCount} κανάλια, {sarCount} δείκτες SAR, {messageCount} μηνύματα", + "@loadedSampleData": { + "description": "Success message after loading sample data", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "Αποτυχία φόρτωσης δειγμάτων δεδομένων: {error}", + "@failedToLoadSampleData": { + "description": "Error message when sample data fails to load", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "Όλα τα δεδομένα διαγράφηκαν", + "@allDataCleared": { + "description": "Success message after clearing all data" + }, + "failedToStartBackgroundTracking": "Αποτυχία εκκίνησης παρακολούθησης στο παρασκήνιο. Ελέγξτε τα δικαιώματα και τη σύνδεση BLE.", + "@failedToStartBackgroundTracking": { + "description": "Error message when background tracking fails to start" + }, + "locationBroadcast": "Μετάδοση τοποθεσίας: {latitude}, {longitude}", + "@locationBroadcast": { + "description": "Success message for location broadcast", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "Το προεπιλεγμένο PIN για συσκευές χωρίς οθόνη είναι 123456. Πρόβλημα στη σύζευξη; Ξεχάστε τη συσκευή Bluetooth από τις ρυθμίσεις συστήματος.", + "@defaultPinInfo": { + "description": "Information about default PIN for pairing" + }, + "noMessagesYet": "Δεν υπάρχουν ακόμη μηνύματα", + "@noMessagesYet": { + "description": "Empty state message when there are no messages" + }, + "pullDownToSync": "Τραβήξτε προς τα κάτω για συγχρονισμό μηνυμάτων", + "@pullDownToSync": { + "description": "Instruction to pull down to refresh messages" + }, + "deleteContact": "Διαγραφή επαφής", + "@deleteContact": { + "description": "Delete contact action label" + }, + "delete": "Διαγραφή", + "@delete": { + "description": "Delete button label" + }, + "viewOnMap": "Προβολή στον χάρτη", + "@viewOnMap": { + "description": "Action to view contact location on map" + }, + "refresh": "Ανανέωση", + "@refresh": { + "description": "Refresh button label" + }, + "resetPath": "Επαναφορά διαδρομής (νέα δρομολόγηση)", + "@resetPath": { + "description": "Action to reset contact path for re-routing" + }, + "copiedToClipboard": "Το {label} αντιγράφηκε στο πρόχειρο", + "@copiedToClipboard": { + "description": "Success message when a value is copied to clipboard", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "Παρακαλώ εισαγάγετε κωδικό", + "@pleaseEnterPassword": { + "description": "Validation message for empty password field" + }, + "failedToSyncContacts": "Αποτυχία συγχρονισμού επαφών: {error}", + "@failedToSyncContacts": { + "description": "Error message when contact sync fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "Η σύνδεση ολοκληρώθηκε με επιτυχία! Αναμονή για μηνύματα δωματίου...", + "@loggedInSuccessfully": { + "description": "Success message after successful room login" + }, + "loginFailed": "Αποτυχία σύνδεσης - λανθασμένος κωδικός", + "@loginFailed": { + "description": "Error message when room login fails" + }, + "loggingIn": "Σύνδεση στο {roomName}...", + "@loggingIn": { + "description": "Status message during room login process", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "Αποτυχία αποστολής σύνδεσης: {error}", + "@failedToSendLogin": { + "description": "Error message when login command fails to send", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "Χαμηλή ακρίβεια τοποθεσίας", + "@lowLocationAccuracy": { + "description": "Warning title for low GPS accuracy" + }, + "continue_": "Συνέχεια", + "@continue_": { + "description": "Continue button label" + }, + "sendSarMarker": "Αποστολή δείκτη SAR", + "@sendSarMarker": { + "description": "Action to send SAR marker" + }, + "deleteDrawing": "Διαγραφή σχεδίου", + "@deleteDrawing": { + "description": "Action to delete a map drawing" + }, + "drawingTools": "Εργαλεία σχεδίασης", + "@drawingTools": { + "description": "Drawing tools section or menu title" + }, + "drawLine": "Σχεδίαση γραμμής", + "@drawLine": { + "description": "Map drawing mode: line" + }, + "drawLineDesc": "Σχεδίαση ελεύθερης γραμμής στον χάρτη", + "@drawLineDesc": { + "description": "Description for line drawing mode" + }, + "drawRectangle": "Σχεδίαση ορθογωνίου", + "@drawRectangle": { + "description": "Map drawing mode: rectangle" + }, + "drawRectangleDesc": "Σχεδίαση ορθογώνιας περιοχής στον χάρτη", + "@drawRectangleDesc": { + "description": "Description for rectangle drawing mode" + }, + "measureDistance": "Μέτρηση απόστασης", + "@measureDistance": { + "description": "Map drawing mode: measure distance" + }, + "measureDistanceDesc": "Παρατεταμένο πάτημα σε δύο σημεία για μέτρηση", + "@measureDistanceDesc": { + "description": "Description for distance measurement mode" + }, + "clearMeasurement": "Εκκαθάριση μέτρησης", + "@clearMeasurement": { + "description": "Tooltip to clear measurement" + }, + "distanceLabel": "Απόσταση: {distance}", + "@distanceLabel": { + "description": "Label showing measured distance", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "Παρατεταμένο πάτημα για δεύτερο σημείο", + "@longPressForSecondPoint": { + "description": "Instruction when first measurement point is set" + }, + "longPressToStartMeasurement": "Παρατεταμένο πάτημα για ορισμό πρώτου σημείου", + "@longPressToStartMeasurement": { + "description": "Instruction to start measurement" + }, + "longPressToStartNewMeasurement": "Παρατεταμένο πάτημα για νέα μέτρηση", + "@longPressToStartNewMeasurement": { + "description": "Instruction to restart measurement after completion" + }, + "shareDrawings": "Κοινοποίηση σχεδίων", + "@shareDrawings": { + "description": "Action to share drawings to network" + }, + "clearAllDrawings": "Εκκαθάριση όλων των σχεδίων", + "@clearAllDrawings": { + "description": "Action to clear all local drawings" + }, + "completeLine": "Ολοκλήρωση γραμμής", + "@completeLine": { + "description": "Tooltip to complete drawing a line" + }, + "broadcastDrawingsToTeam": "Μετάδοση {count} σχεδίου{plural} στην ομάδα", + "@broadcastDrawingsToTeam": { + "description": "Subtitle showing how many drawings will be broadcast", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "Αφαίρεση όλων των {count} σχεδίων{plural}", + "@removeAllDrawings": { + "description": "Subtitle for remove all drawings action", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "Διαγραφή όλων των {count} σχεδίων{plural} από τον χάρτη;", + "@deleteAllDrawingsConfirm": { + "description": "Confirmation dialog message for deleting all drawings", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "Σχέδιο", + "@drawing": { + "description": "Generic drawing label" + }, + "shareDrawingsCount": "Κοινοποίηση {count} σχεδίου{plural}", + "@shareDrawingsCount": { + "description": "Title for share drawings dialog", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "Εμφάνιση ληφθέντων σχεδίων", + "@showReceivedDrawings": { + "description": "Toggle to show/hide received drawings from other team members" + }, + "showingAllDrawings": "Εμφανίζονται όλα τα σχέδια", + "@showingAllDrawings": { + "description": "Subtitle when received drawings are visible" + }, + "showingOnlyYourDrawings": "Εμφανίζονται μόνο τα δικά σας σχέδια", + "@showingOnlyYourDrawings": { + "description": "Subtitle when received drawings are hidden" + }, + "showSarMarkers": "Εμφάνιση δεικτών SAR", + "@showSarMarkers": { + "description": "Toggle to show/hide SAR markers on map" + }, + "showingSarMarkers": "Εμφανίζονται δείκτες SAR", + "@showingSarMarkers": { + "description": "Subtitle when SAR markers are visible" + }, + "hidingSarMarkers": "Απόκρυψη δεικτών SAR", + "@hidingSarMarkers": { + "description": "Subtitle when SAR markers are hidden" + }, + "clearAll": "Εκκαθάριση όλων", + "@clearAll": { + "description": "Clear all button label" + }, + "publicChannel": "Δημόσιο κανάλι", + "@publicChannel": { + "description": "Public channel option for sharing" + }, + "broadcastToAll": "Μετάδοση σε όλους τους κοντινούς κόμβους (προσωρινό)", + "@broadcastToAll": { + "description": "Description for public channel broadcast" + }, + "storedPermanently": "Αποθηκευμένο μόνιμα στο δωμάτιο", + "@storedPermanently": { + "description": "Description for room storage permanence" + }, + "notConnectedToDevice": "Δεν υπάρχει σύνδεση με συσκευή", + "@notConnectedToDevice": { + "description": "Error message when device is not connected for direct messaging" + }, + "typeYourMessage": "Πληκτρολογήστε το μήνυμά σας...", + "@typeYourMessage": { + "description": "Placeholder text for message input field" + }, + "quickLocationMarker": "Γρήγορος δείκτης τοποθεσίας", + "@quickLocationMarker": { + "description": "Subtitle for SAR marker sheet header" + }, + "markerType": "Τύπος δείκτη", + "@markerType": { + "description": "Label for marker type selection section" + }, + "sendTo": "Αποστολή σε", + "@sendTo": { + "description": "Label for destination selection section" + }, + "noDestinationsAvailable": "Δεν υπάρχουν διαθέσιμοι προορισμοί.", + "@noDestinationsAvailable": { + "description": "Warning when no rooms or channels exist" + }, + "selectDestination": "Επιλέξτε προορισμό...", + "@selectDestination": { + "description": "Placeholder for destination dropdown" + }, + "ephemeralBroadcastInfo": "Προσωρινό: Μετάδοση μόνο ασύρματα. Δεν αποθηκεύεται - οι κόμβοι πρέπει να είναι συνδεδεμένοι.", + "@ephemeralBroadcastInfo": { + "description": "Information about ephemeral channel broadcasts" + }, + "persistentRoomInfo": "Μόνιμο: Αποθηκεύεται αμετάβλητα στο δωμάτιο. Συγχρονίζεται αυτόματα και διατηρείται εκτός σύνδεσης.", + "@persistentRoomInfo": { + "description": "Information about persistent room storage" + }, + "location": "Τοποθεσία", + "@location": { + "description": "Label for location section" + }, + "fromMap": "Από χάρτη", + "@fromMap": { + "description": "Badge showing location is from map tap" + }, + "gettingLocation": "Λήψη τοποθεσίας...", + "@gettingLocation": { + "description": "Loading message while fetching GPS location" + }, + "locationError": "Σφάλμα τοποθεσίας", + "@locationError": { + "description": "Title for location error messages" + }, + "retry": "Επανάληψη", + "@retry": { + "description": "Retry button label" + }, + "refreshLocation": "Ανανέωση τοποθεσίας", + "@refreshLocation": { + "description": "Tooltip for refresh location button" + }, + "accuracyMeters": "Ακρίβεια: ±{accuracy}μ", + "@accuracyMeters": { + "description": "Display of GPS accuracy in meters", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "Σημειώσεις (προαιρετικό)", + "@notesOptional": { + "description": "Label for optional notes field" + }, + "addAdditionalInformation": "Προσθέστε επιπλέον πληροφορίες...", + "@addAdditionalInformation": { + "description": "Placeholder for notes field" + }, + "lowAccuracyWarning": "Η ακρίβεια τοποθεσίας είναι ±{accuracy}μ. Αυτό ίσως δεν είναι αρκετά ακριβές για επιχειρήσεις SAR.\n\nΣυνέχεια ούτως ή άλλως;", + "@lowAccuracyWarning": { + "description": "Warning dialog content for low GPS accuracy", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "Σύνδεση στο δωμάτιο", + "@loginToRoom": { + "description": "Title for room login dialog" + }, + "enterPasswordInfo": "Εισαγάγετε τον κωδικό για πρόσβαση σε αυτό το δωμάτιο. Ο κωδικός θα αποθηκευτεί για μελλοντική χρήση.", + "@enterPasswordInfo": { + "description": "Information about room password" + }, + "password": "Κωδικός πρόσβασης", + "@password": { + "description": "Password field label" + }, + "enterRoomPassword": "Εισαγάγετε κωδικό δωματίου", + "@enterRoomPassword": { + "description": "Password field hint" + }, + "loggingInDots": "Σύνδεση...", + "@loggingInDots": { + "description": "Button text while logging in" + }, + "login": "Σύνδεση", + "@login": { + "description": "Login button label" + }, + "failedToAddRoom": "Αποτυχία προσθήκης δωματίου στη συσκευή: {error}\n\nΤο δωμάτιο ίσως δεν έχει μεταδοθεί ακόμη.\nΠεριμένετε να αρχίσει να εκπέμπει.", + "@failedToAddRoom": { + "description": "Error message when adding room fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "Άμεσο", + "@direct": { + "description": "Direct routing indicator" + }, + "flood": "Πλημμυρικό", + "@flood": { + "description": "Flood routing indicator" + }, + "loggedIn": "Συνδεδεμένος", + "@loggedIn": { + "description": "Logged in status badge" + }, + "noGpsData": "Δεν υπάρχουν δεδομένα GPS", + "@noGpsData": { + "description": "Message when GPS data is not available" + }, + "distance": "Απόσταση", + "@distance": { + "description": "Distance label" + }, + "directPingTimeout": "Λήξη χρόνου άμεσου ping - επανάληψη προς {name} με flooding...", + "@directPingTimeout": { + "description": "Warning when direct ping times out", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "Αποτυχία ping προς {name} - δεν ελήφθη απάντηση", + "@pingFailed": { + "description": "Error message when ping fails", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το \"{name}\";\n\nΑυτό θα αφαιρέσει την επαφή τόσο από την εφαρμογή όσο και από τη συνοδευτική συσκευή radio.", + "@deleteContactConfirmation": { + "description": "Confirmation message for deleting contact", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "Αποτυχία αφαίρεσης επαφής: {error}", + "@failedToRemoveContact": { + "description": "Error message when contact removal fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "Τύπος", + "@type": { + "description": "Contact type label" + }, + "publicKey": "Δημόσιο κλειδί", + "@publicKey": { + "description": "Public key label" + }, + "lastSeen": "Τελευταία εμφάνιση", + "@lastSeen": { + "description": "Last seen label" + }, + "yes": "Ναι", + "@yes": { + "description": "Yes answer" + }, + "no": "Όχι", + "@no": { + "description": "No answer" + }, + "permissions": "Δικαιώματα", + "@permissions": { + "description": "Permissions label" + }, + "telemetry": "Τηλεμετρία", + "@telemetry": { + "description": "Telemetry section header" + }, + "voltage": "Τάση", + "@voltage": { + "description": "Voltage label" + }, + "battery": "Μπαταρία", + "@battery": { + "description": "Battery label" + }, + "temperature": "Θερμοκρασία", + "@temperature": { + "description": "Temperature label" + }, + "humidity": "Υγρασία", + "@humidity": { + "description": "Humidity label" + }, + "pressure": "Πίεση", + "@pressure": { + "description": "Pressure label" + }, + "gpsTelemetry": "GPS (τηλεμετρία)", + "@gpsTelemetry": { + "description": "GPS from telemetry label" + }, + "reLoginToRoom": "Επανασύνδεση στο δωμάτιο", + "@reLoginToRoom": { + "description": "Button to re-login to room" + }, + "heading": "Κατεύθυνση", + "@heading": { + "description": "Compass heading label" + }, + "elevation": "Υψόμετρο", + "@elevation": { + "description": "Elevation/altitude label" + }, + "accuracy": "Ακρίβεια", + "@accuracy": { + "description": "GPS accuracy label" + }, + "bearing": "Πορεία", + "@bearing": { + "description": "Bearing label in compass" + }, + "direction": "Κατεύθυνση", + "@direction": { + "description": "Direction label in compass" + }, + "filterMarkers": "Φιλτράρισμα δεικτών", + "@filterMarkers": { + "description": "Title for filter markers dialog" + }, + "filterMarkersTooltip": "Φιλτράρισμα δεικτών", + "@filterMarkersTooltip": { + "description": "Tooltip for filter button" + }, + "contactsFilter": "Επαφές", + "@contactsFilter": { + "description": "Filter option for contacts" + }, + "repeatersFilter": "Αναμεταδότες", + "@repeatersFilter": { + "description": "Filter option for repeaters" + }, + "sarMarkers": "Δείκτες SAR", + "@sarMarkers": { + "description": "SAR markers section header" + }, + "foundPerson": "Εντοπισμένο άτομο", + "@foundPerson": { + "description": "Found person SAR marker type" + }, + "fire": "Φωτιά", + "@fire": { + "description": "Fire SAR marker type" + }, + "stagingArea": "Σημείο συγκέντρωσης", + "@stagingArea": { + "description": "Staging area SAR marker type" + }, + "showAll": "Εμφάνιση όλων", + "@showAll": { + "description": "Button to show all filters" + }, + "locationUnavailable": "Η τοποθεσία δεν είναι διαθέσιμη", + "@locationUnavailable": { + "description": "Message when GPS location is unavailable" + }, + "ahead": "μπροστά", + "@ahead": { + "description": "Relative bearing direction - ahead" + }, + "degreesRight": "{degrees}° δεξιά", + "@degreesRight": { + "description": "Relative bearing direction - right", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "{degrees}° αριστερά", + "@degreesLeft": { + "description": "Relative bearing direction - left", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "Γεωγρ. πλάτος: {latitude} Γεωγρ. μήκος: {longitude}", + "@latLonFormat": { + "description": "Latitude and longitude display format", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "Δεν υπάρχουν ακόμη επαφές", + "@noContactsYet": { + "description": "Empty state message when there are no contacts" + }, + "connectToDeviceToLoadContacts": "Συνδεθείτε σε συσκευή για φόρτωση επαφών", + "@connectToDeviceToLoadContacts": { + "description": "Instruction to connect device to load contacts" + }, + "teamMembers": "Μέλη ομάδας", + "@teamMembers": { + "description": "Section header for team members (chat contacts)" + }, + "repeaters": "Αναμεταδότες", + "@repeaters": { + "description": "Section header for repeater nodes" + }, + "rooms": "Δωμάτια", + "@rooms": { + "description": "Section header for rooms" + }, + "channels": "Κανάλια", + "@channels": { + "description": "Section header for broadcast channels" + }, + "selectMapLayer": "Επιλογή επιπέδου χάρτη", + "@selectMapLayer": { + "description": "Title for map layer selection dialog" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "OpenStreetMap layer name" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "OpenTopoMap layer name" + }, + "esriSatellite": "Δορυφορικός ESRI", + "@esriSatellite": { + "description": "ESRI Satellite imagery layer name" + }, + "googleHybrid": "Υβριδικός Google", + "@googleHybrid": { + "description": "Google Hybrid layer name (satellite + labels)" + }, + "googleRoadmap": "Οδικός χάρτης Google", + "@googleRoadmap": { + "description": "Google Roadmap layer name (street map)" + }, + "googleTerrain": "Ανάγλυφο Google", + "@googleTerrain": { + "description": "Google Terrain layer name (topographic)" + }, + "dragToPosition": "Σύρετε στη θέση", + "@dragToPosition": { + "description": "Label when dragging a pin on map" + }, + "createSarMarker": "Δημιουργία δείκτη SAR", + "@createSarMarker": { + "description": "Label for creating SAR marker from pin" + }, + "compass": "Πυξίδα", + "@compass": { + "description": "Compass title in detailed compass dialog" + }, + "navigationAndContacts": "Πλοήγηση και επαφές", + "@navigationAndContacts": { + "description": "Subtitle for compass dialog" + }, + "sarAlert": "ΣΥΝΑΓΕΡΜΟΣ SAR", + "@sarAlert": { + "description": "Label for SAR alert badge on messages" + }, + "textCopiedToClipboard": "Το κείμενο αντιγράφηκε στο πρόχειρο", + "@textCopiedToClipboard": { + "description": "Success message when text is copied" + }, + "cannotReplySenderMissing": "Αδυναμία απάντησης: λείπουν πληροφορίες αποστολέα", + "@cannotReplySenderMissing": { + "description": "Error when sender info is missing for reply" + }, + "cannotReplyContactNotFound": "Αδυναμία απάντησης: η επαφή δεν βρέθηκε", + "@cannotReplyContactNotFound": { + "description": "Error when contact not found for reply" + }, + "copyText": "Αντιγραφή κειμένου", + "@copyText": { + "description": "Option to copy message text to clipboard" + }, + "saveAsTemplate": "Αποθήκευση ως πρότυπο", + "@saveAsTemplate": { + "description": "Option to save SAR message as a reusable template" + }, + "templateSaved": "Το πρότυπο αποθηκεύτηκε επιτυχώς", + "@templateSaved": { + "description": "Success message when SAR template is saved" + }, + "templateAlreadyExists": "Υπάρχει ήδη πρότυπο με αυτό το emoji", + "@templateAlreadyExists": { + "description": "Error message when trying to save duplicate template" + }, + "deleteMessage": "Διαγραφή μηνύματος", + "@deleteMessage": { + "description": "Dialog title for deleting a message" + }, + "deleteMessageConfirmation": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το μήνυμα;", + "@deleteMessageConfirmation": { + "description": "Confirmation text for message deletion" + }, + "shareLocation": "Κοινοποίηση τοποθεσίας", + "@shareLocation": { + "description": "Option to share SAR marker location" + }, + "shareLocationText": "{markerInfo}\n\nΣυντεταγμένες: {lat}, {lon}\n\nGoogle Maps: {url}", + "@shareLocationText": { + "description": "Formatted text for sharing SAR marker location", + "placeholders": { + "markerInfo": { + "type": "String" + }, + "lat": { + "type": "String" + }, + "lon": { + "type": "String" + }, + "url": { + "type": "String" + } + } + }, + "sarLocationShare": "Τοποθεσία SAR", + "@sarLocationShare": { + "description": "Subject line when sharing SAR marker location" + }, + "justNow": "Μόλις τώρα", + "@justNow": { + "description": "Time indicator for very recent activity" + }, + "minutesAgo": "πριν από {minutes}λ", + "@minutesAgo": { + "description": "Time indicator for minutes ago", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "πριν από {hours}ω", + "@hoursAgo": { + "description": "Time indicator for hours ago", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "πριν από {days}η", + "@daysAgo": { + "description": "Time indicator for days ago", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "πριν από {seconds}δ", + "@secondsAgo": { + "description": "Time indicator for seconds ago", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "Αποστολή...", + "@sending": { + "description": "Delivery status: sending" + }, + "sent": "Στάλθηκε", + "@sent": { + "description": "Delivery status: sent" + }, + "delivered": "Παραδόθηκε", + "@delivered": { + "description": "Delivery status: delivered" + }, + "deliveredWithTime": "Παραδόθηκε ({time}ms)", + "@deliveredWithTime": { + "description": "Delivery status with round-trip time", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "Απέτυχε", + "@failed": { + "description": "Delivery status: failed" + }, + "broadcast": "Μετάδοση", + "@broadcast": { + "description": "Delivery status for channel messages (no echoes yet)" + }, + "deliveredToContacts": "Παραδόθηκε σε {delivered}/{total} επαφές", + "@deliveredToContacts": { + "description": "Grouped message delivery count", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "Όλα παραδόθηκαν", + "@allDelivered": { + "description": "Status when all recipients received the message" + }, + "recipientDetails": "Λεπτομέρειες παραλήπτη", + "@recipientDetails": { + "description": "Header for expandable recipient list" + }, + "pending": "Σε εκκρεμότητα", + "@pending": { + "description": "Delivery status: pending/waiting" + }, + "sarMarkerFoundPerson": "Εντοπισμένο άτομο", + "@sarMarkerFoundPerson": { + "description": "SAR marker type: found person" + }, + "sarMarkerFire": "Σημείο φωτιάς", + "@sarMarkerFire": { + "description": "SAR marker type: fire" + }, + "sarMarkerStagingArea": "Σημείο συγκέντρωσης", + "@sarMarkerStagingArea": { + "description": "SAR marker type: staging area" + }, + "sarMarkerObject": "Εντοπισμένο αντικείμενο", + "@sarMarkerObject": { + "description": "SAR marker type: object" + }, + "from": "Από", + "@from": { + "description": "Sender label in notifications" + }, + "coordinates": "Συντεταγμένες", + "@coordinates": { + "description": "Coordinates label" + }, + "tapToViewOnMap": "Πατήστε για προβολή στον χάρτη", + "@tapToViewOnMap": { + "description": "Notification action text" + }, + "radioSettings": "Ρυθμίσεις radio", + "@radioSettings": { + "description": "Section title for radio settings" + }, + "frequencyMHz": "Συχνότητα (MHz)", + "@frequencyMHz": { + "description": "Label for radio frequency field" + }, + "bandwidth": "Εύρος ζώνης", + "@bandwidth": { + "description": "Label for bandwidth dropdown" + }, + "spreadingFactor": "Συντελεστής εξάπλωσης", + "@spreadingFactor": { + "description": "Label for spreading factor dropdown" + }, + "codingRate": "Ρυθμός κωδικοποίησης", + "@codingRate": { + "description": "Label for coding rate dropdown" + }, + "txPowerDbm": "Ισχύς TX (dBm)", + "@txPowerDbm": { + "description": "Label for TX power field" + }, + "maxPowerDbm": "Μέγ.: {power} dBm", + "@maxPowerDbm": { + "description": "Helper text showing maximum TX power", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "Εσείς", + "@you": { + "description": "Label for the current user in message bubbles" + }, + "exportFailed": "Η εξαγωγή απέτυχε: {error}", + "@exportFailed": { + "description": "Error message when export fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "Η εισαγωγή απέτυχε: {error}", + "@importFailed": { + "description": "Error message when import fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "Άγνωστο", + "@unknown": { + "description": "Unknown value label" + }, + "onlineLayers": "Επίπεδα online", + "@onlineLayers": { + "description": "Section header for online map layers" + }, + "locationTrail": "Ίχνος τοποθεσίας", + "@locationTrail": { + "description": "Location trail title" + }, + "showTrailOnMap": "Εμφάνιση ίχνους στον χάρτη", + "@showTrailOnMap": { + "description": "Toggle to show/hide trail on map" + }, + "trailVisible": "Το ίχνος είναι ορατό στον χάρτη", + "@trailVisible": { + "description": "Trail visibility status - visible" + }, + "trailHiddenRecording": "Το ίχνος είναι κρυφό (συνεχίζεται η καταγραφή)", + "@trailHiddenRecording": { + "description": "Trail visibility status - hidden but recording" + }, + "duration": "Διάρκεια", + "@duration": { + "description": "Duration label" + }, + "points": "Σημεία", + "@points": { + "description": "Trail points count label" + }, + "clearTrail": "Εκκαθάριση ίχνους", + "@clearTrail": { + "description": "Button to clear location trail" + }, + "clearTrailQuestion": "Εκκαθάριση ίχνους;", + "@clearTrailQuestion": { + "description": "Confirmation dialog title" + }, + "clearTrailConfirmation": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το τρέχον ίχνος τοποθεσίας; Αυτή η ενέργεια δεν αναιρείται.", + "@clearTrailConfirmation": { + "description": "Confirmation dialog message" + }, + "noTrailRecorded": "Δεν έχει καταγραφεί ακόμη ίχνος", + "@noTrailRecorded": { + "description": "Message when no trail exists" + }, + "startTrackingToRecord": "Ξεκινήστε παρακολούθηση τοποθεσίας για καταγραφή ίχνους", + "@startTrackingToRecord": { + "description": "Instructions to start trail recording" + }, + "trailControls": "Στοιχεία ελέγχου ίχνους", + "@trailControls": { + "description": "Trail controls tooltip" + }, + "model": "Μοντέλο", + "@model": { + "description": "Device model label" + }, + "version": "Έκδοση", + "@version": { + "description": "Version label" + }, + "maxContacts": "Μέγιστες επαφές", + "@maxContacts": { + "description": "Maximum contacts capacity label" + }, + "maxChannels": "Μέγιστα κανάλια", + "@maxChannels": { + "description": "Maximum channels capacity label" + }, + "publicInfo": "Δημόσιες πληροφορίες", + "@publicInfo": { + "description": "Public information section header" + }, + "useCurrentLocation": "Χρήση τρέχουσας τοποθεσίας", + "@useCurrentLocation": { + "description": "Tooltip for use current location button" + }, + "noneUnknown": "Κανένα/Άγνωστο", + "@noneUnknown": { + "description": "Device type: none or unknown" + }, + "chatNode": "Κόμβος συνομιλίας", + "@chatNode": { + "description": "Device type: chat node" + }, + "repeater": "Αναμεταδότης", + "@repeater": { + "description": "Device type: repeater" + }, + "roomChannel": "Δωμάτιο/Κανάλι", + "@roomChannel": { + "description": "Device type: room or channel" + }, + "typeNumber": "Τύπος {number}", + "@typeNumber": { + "description": "Generic device type with number", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "Αποτυχία αποθήκευσης: {error}", + "@failedToSave": { + "description": "Generic error message for save failures", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "Αποτυχία λήψης τοποθεσίας: {error}", + "@failedToGetLocation": { + "description": "Error message when getting location fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "Πρότυπα SAR", + "@sarTemplates": { + "description": "SAR templates menu title" + }, + "addTemplate": "Προσθήκη προτύπου", + "@addTemplate": { + "description": "Button to add new SAR template" + }, + "editTemplate": "Επεξεργασία προτύπου", + "@editTemplate": { + "description": "Dialog title for editing template" + }, + "deleteTemplate": "Διαγραφή προτύπου", + "@deleteTemplate": { + "description": "Action to delete template" + }, + "templateName": "Όνομα προτύπου", + "@templateName": { + "description": "Label for template name field" + }, + "templateNameHint": "π.χ. Εντοπισμένο άτομο", + "@templateNameHint": { + "description": "Hint text for template name" + }, + "templateEmoji": "Emoji", + "@templateEmoji": { + "description": "Label for template emoji field" + }, + "emojiRequired": "Το emoji είναι υποχρεωτικό", + "@emojiRequired": { + "description": "Validation error when emoji field is empty" + }, + "nameRequired": "Το όνομα είναι υποχρεωτικό", + "@nameRequired": { + "description": "Validation error when name field is empty" + }, + "templateDescription": "Περιγραφή (προαιρετική)", + "@templateDescription": { + "description": "Label for template description field" + }, + "templateDescriptionHint": "Προσθέστε επιπλέον πληροφορίες...", + "@templateDescriptionHint": { + "description": "Hint text for template description" + }, + "templateColor": "Χρώμα", + "@templateColor": { + "description": "Label for template color picker" + }, + "previewFormat": "Προεπισκόπηση (μορφή μηνύματος SAR)", + "@previewFormat": { + "description": "Label for format preview" + }, + "importFromClipboard": "Εισαγωγή", + "@importFromClipboard": { + "description": "Button to import templates from clipboard" + }, + "exportToClipboard": "Εξαγωγή", + "@exportToClipboard": { + "description": "Button to export templates to clipboard" + }, + "deleteTemplateConfirmation": "Διαγραφή προτύπου «{name}»;", + "@deleteTemplateConfirmation": { + "description": "Confirmation message for template deletion", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "templateAdded": "Το πρότυπο προστέθηκε", + "@templateAdded": { + "description": "Success message when template is added" + }, + "templateUpdated": "Το πρότυπο ενημερώθηκε", + "@templateUpdated": { + "description": "Success message when template is updated" + }, + "templateDeleted": "Το πρότυπο διαγράφηκε", + "@templateDeleted": { + "description": "Success message when template is deleted" + }, + "templatesImported": "{count, plural, =0{Δεν εισήχθησαν πρότυπα} =1{Εισήχθη 1 πρότυπο} other{Εισήχθησαν {count} πρότυπα}}", + "@templatesImported": { + "description": "Success message after importing templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "templatesExported": "{count, plural, =1{Εξήχθη 1 πρότυπο στο πρόχειρο} other{Εξήχθησαν {count} πρότυπα στο πρόχειρο}}", + "@templatesExported": { + "description": "Success message after exporting templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "resetToDefaults": "Επαναφορά προεπιλογών", + "@resetToDefaults": { + "description": "Action to reset templates to defaults" + }, + "resetToDefaultsConfirmation": "Αυτό θα διαγράψει όλα τα προσαρμοσμένα πρότυπα και θα επαναφέρει τα 4 προεπιλεγμένα πρότυπα. Συνέχεια;", + "@resetToDefaultsConfirmation": { + "description": "Confirmation message for reset to defaults" + }, + "reset": "Επαναφορά", + "@reset": { + "description": "Reset button label" + }, + "resetComplete": "Τα πρότυπα επανήλθαν στις προεπιλογές", + "@resetComplete": { + "description": "Success message after reset" + }, + "noTemplates": "Δεν υπάρχουν διαθέσιμα πρότυπα", + "@noTemplates": { + "description": "Message when no templates exist" + }, + "tapAddToCreate": "Πατήστε + για να δημιουργήσετε το πρώτο σας πρότυπο", + "@tapAddToCreate": { + "description": "Helper text when no templates exist" + }, + "ok": "ΟΚ", + "@ok": { + "description": "OK button label" + }, + "locationPermission": "Άδεια τοποθεσίας", + "@locationPermission": { + "description": "Location permission label" + }, + "checking": "Έλεγχος...", + "@checking": { + "description": "Loading state indicator" + }, + "locationPermissionGrantedAlways": "Δόθηκε (πάντα)", + "@locationPermissionGrantedAlways": { + "description": "Location permission status: granted always" + }, + "locationPermissionGrantedWhileInUse": "Δόθηκε (κατά τη χρήση)", + "@locationPermissionGrantedWhileInUse": { + "description": "Location permission status: granted while in use" + }, + "locationPermissionDeniedTapToRequest": "Απορρίφθηκε - πατήστε για αίτημα", + "@locationPermissionDeniedTapToRequest": { + "description": "Location permission status: denied, user can request" + }, + "locationPermissionPermanentlyDeniedOpenSettings": "Μόνιμα απορριφθείσα - άνοιγμα ρυθμίσεων", + "@locationPermissionPermanentlyDeniedOpenSettings": { + "description": "Location permission status: permanently denied" + }, + "locationPermissionDialogContent": "Η άδεια τοποθεσίας έχει απορριφθεί μόνιμα. Ενεργοποιήστε την από τις ρυθμίσεις της συσκευής σας για να χρησιμοποιήσετε παρακολούθηση GPS και κοινοποίηση τοποθεσίας.", + "@locationPermissionDialogContent": { + "description": "Content for location permission dialog when permanently denied" + }, + "openSettings": "Άνοιγμα ρυθμίσεων", + "@openSettings": { + "description": "Button to open device settings" + }, + "locationPermissionGranted": "Η άδεια τοποθεσίας δόθηκε!", + "@locationPermissionGranted": { + "description": "Success message when location permission is granted" + }, + "locationPermissionRequiredForGps": "Απαιτείται άδεια τοποθεσίας για παρακολούθηση GPS και κοινοποίηση τοποθεσίας.", + "@locationPermissionRequiredForGps": { + "description": "Info message about location permission requirement" + }, + "locationPermissionAlreadyGranted": "Η άδεια τοποθεσίας έχει ήδη δοθεί.", + "@locationPermissionAlreadyGranted": { + "description": "Info message when permission is already granted" + }, + "sarNavyBlue": "SAR Ναυτικό μπλε", + "@sarNavyBlue": { + "description": "SAR Navy Blue theme name" + }, + "sarNavyBlueDescription": "Επαγγελματική/Επιχειρησιακή λειτουργία", + "@sarNavyBlueDescription": { + "description": "Description for SAR Navy Blue theme" + }, + "selectRecipient": "Επιλογή παραλήπτη", + "@selectRecipient": { + "description": "Title for recipient selector sheet" + }, + "broadcastToAllNearby": "Μετάδοση σε όλους τους κοντινούς", + "@broadcastToAllNearby": { + "description": "Subtitle for public channel option" + }, + "searchRecipients": "Αναζήτηση παραληπτών...", + "@searchRecipients": { + "description": "Placeholder text for recipient search field" + }, + "noContactsFound": "Δεν βρέθηκαν επαφές", + "@noContactsFound": { + "description": "Message when no contacts match search" + }, + "noRoomsFound": "Δεν βρέθηκαν δωμάτια", + "@noRoomsFound": { + "description": "Message when no rooms match search" + }, + "noRecipientsAvailable": "Δεν υπάρχουν διαθέσιμοι παραλήπτες", + "@noRecipientsAvailable": { + "description": "Message when no recipients exist (contacts, rooms, or channels)" + }, + "noChannelsFound": "Δεν βρέθηκαν κανάλια", + "@noChannelsFound": { + "description": "Message when no channels match the search" + }, + "newMessage": "Νέο μήνυμα", + "@newMessage": { + "description": "Notification title for new message" + }, + "channel": "Κανάλι", + "@channel": { + "description": "Channel label in notifications" + }, + "samplePoliceLead": "Επικεφαλής αστυνομίας", + "@samplePoliceLead": { + "description": "Sample team member name" + }, + "sampleDroneOperator": "Χειριστής drone", + "@sampleDroneOperator": { + "description": "Sample team member name" + }, + "sampleFirefighterAlpha": "Πυροσβέστης", + "@sampleFirefighterAlpha": { + "description": "Sample team member name" + }, + "sampleMedicCharlie": "Διασώστης", + "@sampleMedicCharlie": { + "description": "Sample team member name" + }, + "sampleCommandDelta": "Διοίκηση", + "@sampleCommandDelta": { + "description": "Sample team member name" + }, + "sampleFireEngine": "Πυροσβεστικό όχημα", + "@sampleFireEngine": { + "description": "Sample team member name" + }, + "sampleAirSupport": "Εναέρια υποστήριξη", + "@sampleAirSupport": { + "description": "Sample team member name" + }, + "sampleBaseCoordinator": "Συντονιστής βάσης", + "@sampleBaseCoordinator": { + "description": "Sample team member name" + }, + "channelEmergency": "Έκτακτη ανάγκη", + "@channelEmergency": { + "description": "Emergency channel name" + }, + "channelCoordination": "Συντονισμός", + "@channelCoordination": { + "description": "Coordination channel name" + }, + "channelUpdates": "Ενημερώσεις", + "@channelUpdates": { + "description": "Updates channel name" + }, + "sampleTeamMember": "Δοκιμαστικό μέλος ομάδας", + "@sampleTeamMember": { + "description": "Sample sender name" + }, + "sampleScout": "Δοκιμαστικός ανιχνευτής", + "@sampleScout": { + "description": "Sample sender name" + }, + "sampleBase": "Δοκιμαστική βάση", + "@sampleBase": { + "description": "Sample sender name" + }, + "sampleSearcher": "Δοκιμαστικός ερευνητής", + "@sampleSearcher": { + "description": "Sample sender name" + }, + "sampleObjectBackpack": " Βρέθηκε σακίδιο - μπλε χρώμα", + "@sampleObjectBackpack": { + "description": "Sample object note" + }, + "sampleObjectVehicle": " Εγκαταλελειμμένο όχημα - έλεγχος για ιδιοκτήτη", + "@sampleObjectVehicle": { + "description": "Sample object note" + }, + "sampleObjectCamping": " Εντοπίστηκε εξοπλισμός κατασκήνωσης", + "@sampleObjectCamping": { + "description": "Sample object note" + }, + "sampleObjectTrailMarker": " Βρέθηκε σημάδι μονοπατιού εκτός διαδρομής", + "@sampleObjectTrailMarker": { + "description": "Sample object note" + }, + "sampleMsgAllTeamsCheckIn": "Όλες οι ομάδες, κάντε check-in", + "@sampleMsgAllTeamsCheckIn": { + "description": "Sample channel message" + }, + "sampleMsgWeatherUpdate": "Ενημέρωση καιρού: Αίθριος ουρανός, θερμ. 18°C", + "@sampleMsgWeatherUpdate": { + "description": "Sample channel message" + }, + "sampleMsgBaseCamp": "Η βάση εγκαταστάθηκε στο σημείο συγκέντρωσης", + "@sampleMsgBaseCamp": { + "description": "Sample channel message" + }, + "sampleMsgTeamAlpha": "Η ομάδα κινείται προς τον τομέα 2", + "@sampleMsgTeamAlpha": { + "description": "Sample channel message" + }, + "sampleMsgRadioCheck": "Έλεγχος ασυρμάτου - όλοι οι σταθμοί να απαντήσουν", + "@sampleMsgRadioCheck": { + "description": "Sample channel message" + }, + "sampleMsgWaterSupply": "Διαθέσιμο νερό στο σημείο ελέγχου 3", + "@sampleMsgWaterSupply": { + "description": "Sample channel message" + }, + "sampleMsgTeamBravo": "Αναφορά ομάδας: ο τομέας 1 είναι καθαρός", + "@sampleMsgTeamBravo": { + "description": "Sample channel message" + }, + "sampleMsgEtaRallyPoint": "Εκτιμώμενη άφιξη στο σημείο συγκέντρωσης: 15 λεπτά", + "@sampleMsgEtaRallyPoint": { + "description": "Sample channel message" + }, + "sampleMsgSupplyDrop": "Η ρίψη εφοδίων επιβεβαιώθηκε για τις 14:00", + "@sampleMsgSupplyDrop": { + "description": "Sample channel message" + }, + "sampleMsgDroneSurvey": "Η έρευνα με drone ολοκληρώθηκε - δεν βρέθηκε κάτι", + "@sampleMsgDroneSurvey": { + "description": "Sample channel message" + }, + "sampleMsgTeamCharlie": "Η ομάδα ζητά ενισχύσεις", + "@sampleMsgTeamCharlie": { + "description": "Sample channel message" + }, + "sampleMsgRadioDiscipline": "Όλες οι μονάδες: τηρείτε πειθαρχία ασυρμάτου", + "@sampleMsgRadioDiscipline": { + "description": "Sample channel message" + }, + "sampleMsgUrgentMedical": "ΕΠΕΙΓΟΝ: Απαιτείται ιατρική βοήθεια στον τομέα 4", + "@sampleMsgUrgentMedical": { + "description": "Sample emergency message" + }, + "sampleMsgAdultMale": " Ενήλικος άνδρας, σε συνείδηση", + "@sampleMsgAdultMale": { + "description": "Sample emergency message note" + }, + "sampleMsgFireSpotted": "Εντοπίστηκε φωτιά - ακολουθούν συντεταγμένες", + "@sampleMsgFireSpotted": { + "description": "Sample emergency message" + }, + "sampleMsgSpreadingRapidly": " Εξαπλώνεται γρήγορα!", + "@sampleMsgSpreadingRapidly": { + "description": "Sample emergency message note" + }, + "sampleMsgPriorityHelicopter": "ΠΡΟΤΕΡΑΙΟΤΗΤΑ: Απαιτείται υποστήριξη ελικοπτέρου", + "@sampleMsgPriorityHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgMedicalTeamEnRoute": "Η ιατρική ομάδα κατευθύνεται στη θέση σας", + "@sampleMsgMedicalTeamEnRoute": { + "description": "Sample emergency message" + }, + "sampleMsgEvacHelicopter": "Ελικόπτερο εκκένωσης σε 10 λεπτά", + "@sampleMsgEvacHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyResolved": "Το περιστατικό επιλύθηκε - όλα καθαρά", + "@sampleMsgEmergencyResolved": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyStagingArea": " Χώρος συγκέντρωσης έκτακτης ανάγκης", + "@sampleMsgEmergencyStagingArea": { + "description": "Sample emergency message note" + }, + "sampleMsgEmergencyServices": "Οι υπηρεσίες έκτακτης ανάγκης ειδοποιήθηκαν και ανταποκρίνονται", + "@sampleMsgEmergencyServices": { + "description": "Sample emergency message" + }, + "sampleAlphaTeamLead": "Αρχηγός ομάδας", + "@sampleAlphaTeamLead": { + "description": "Sample team name" + }, + "sampleBravoScout": "Ανιχνευτής", + "@sampleBravoScout": { + "description": "Sample team name" + }, + "sampleCharlieMedic": "Διασώστης", + "@sampleCharlieMedic": { + "description": "Sample team name" + }, + "sampleDeltaNavigator": "Πλοηγός", + "@sampleDeltaNavigator": { + "description": "Sample team name" + }, + "sampleEchoSupport": "Υποστήριξη", + "@sampleEchoSupport": { + "description": "Sample team name" + }, + "sampleBaseCommand": "Διοίκηση βάσης", + "@sampleBaseCommand": { + "description": "Sample team name" + }, + "sampleFieldCoordinator": "Συντονιστής πεδίου", + "@sampleFieldCoordinator": { + "description": "Sample team name" + }, + "sampleMedicalTeam": "Ιατρική ομάδα", + "@sampleMedicalTeam": { + "description": "Sample team name" + }, + "mapDrawing": "Σχέδιο χάρτη", + "@mapDrawing": { + "description": "Label for map drawing messages" + }, + "navigateToDrawing": "Πλοήγηση προς το σχέδιο", + "@navigateToDrawing": { + "description": "Option to navigate to drawing on map" + }, + "copyCoordinates": "Αντιγραφή συντεταγμένων", + "@copyCoordinates": { + "description": "Option to copy coordinates to clipboard" + }, + "hideFromMap": "Απόκρυψη από χάρτη", + "@hideFromMap": { + "description": "Option to hide drawing from map" + }, + "lineDrawing": "Σχέδιο γραμμής", + "@lineDrawing": { + "description": "Label for line type drawings" + }, + "rectangleDrawing": "Σχέδιο ορθογωνίου", + "@rectangleDrawing": { + "description": "Label for rectangle type drawings" + }, + "manualCoordinates": "Χειροκίνητες συντεταγμένες", + "@manualCoordinates": { + "description": "Label for manual coordinate input toggle" + }, + "enterCoordinatesManually": "Εισαγωγή συντεταγμένων χειροκίνητα", + "@enterCoordinatesManually": { + "description": "Description for manual coordinate input option" + }, + "latitudeLabel": "Γεωγραφικό πλάτος", + "@latitudeLabel": { + "description": "Label for latitude input field" + }, + "longitudeLabel": "Γεωγραφικό μήκος", + "@longitudeLabel": { + "description": "Label for longitude input field" + }, + "exampleCoordinates": "Παράδειγμα: 46.0569, 14.5058", + "@exampleCoordinates": { + "description": "Example coordinate format hint" + }, + "shareDrawing": "Κοινοποίηση σχεδίου", + "@shareDrawing": { + "description": "Title for share single drawing dialog" + }, + "shareWithAllNearbyDevices": "Κοινοποίηση σε όλες τις κοντινές συσκευές", + "@shareWithAllNearbyDevices": { + "description": "Subtitle for public channel sharing option" + }, + "shareToRoom": "Κοινοποίηση στο δωμάτιο", + "@shareToRoom": { + "description": "Header for room sharing section" + }, + "sendToPersistentStorage": "Αποστολή σε μόνιμη αποθήκευση δωματίου", + "@sendToPersistentStorage": { + "description": "Subtitle for room sharing option" + }, + "deleteDrawingConfirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το σχέδιο;", + "@deleteDrawingConfirm": { + "description": "Confirmation message for deleting a single drawing" + }, + "drawingDeleted": "Το σχέδιο διαγράφηκε", + "@drawingDeleted": { + "description": "Success message after deleting a drawing" + }, + "yourDrawingsCount": "Τα σχέδιά σας ({count})", + "@yourDrawingsCount": { + "description": "Header showing count of user's drawings", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shared": "Κοινοποιημένο", + "@shared": { + "description": "Status label for shared drawings" + }, + "line": "Γραμμή", + "@line": { + "description": "Line drawing type label" + }, + "rectangle": "Ορθογώνιο", + "@rectangle": { + "description": "Rectangle drawing type label" + }, + "updateAvailable": "Διαθέσιμη ενημέρωση", + "@updateAvailable": { + "description": "Title for update dialog when new version is available" + }, + "currentVersion": "Τρέχουσα", + "@currentVersion": { + "description": "Label for current app version" + }, + "latestVersion": "Τελευταία", + "@latestVersion": { + "description": "Label for latest available app version" + }, + "downloadUpdate": "Λήψη", + "@downloadUpdate": { + "description": "Button to download app update" + }, + "updateLater": "Αργότερα", + "@updateLater": { + "description": "Button to dismiss update dialog" + }, + "cadastralParcels": "Κτηματολογικά τεμάχια", + "@cadastralParcels": { + "description": "Label for cadastral parcels WMS overlay layer" + }, + "forestRoads": "Δασικοί δρόμοι", + "@forestRoads": { + "description": "Label for forest roads WMS overlay layer" + }, + "wmsOverlays": "Επικαλύψεις WMS", + "@wmsOverlays": { + "description": "Section header for WMS overlay layers in layer selector" + }, + "hikingTrails": "Πεζοπορικά μονοπάτια", + "@hikingTrails": { + "description": "Label for hiking/mountain trails WMS overlay layer" + }, + "mainRoads": "Κύριοι δρόμοι", + "@mainRoads": { + "description": "Label for main roads WMS overlay layer" + }, + "houseNumbers": "Αριθμοί κτιρίων", + "@houseNumbers": { + "description": "Label for house numbers WMS overlay layer" + }, + "fireHazardZones": "Ζώνες κινδύνου πυρκαγιάς", + "@fireHazardZones": { + "description": "Label for fire hazard risk zones WMS overlay layer" + }, + "historicalFires": "Ιστορικές πυρκαγιές", + "@historicalFires": { + "description": "Label for historical forest fires WMS overlay layer" + }, + "firebreaks": "Αντιπυρικές ζώνες", + "@firebreaks": { + "description": "Label for firebreaks WMS overlay layer" + }, + "krasFireZones": "Ζώνες πυρκαγιάς Κρας", + "@krasFireZones": { + "description": "Label for Kras fire zones WMS overlay layer" + }, + "placeNames": "Τοπωνύμια", + "@placeNames": { + "description": "Label for geographic place names WMS overlay layer" + }, + "municipalityBorders": "Όρια δήμων", + "@municipalityBorders": { + "description": "Label for municipality borders WMS overlay layer" + }, + "topographicMap": "Τοπογραφικός χάρτης 1:25000", + "@topographicMap": { + "description": "Label for DTK25 topographic base map layer" + }, + "recentMessages": "Πρόσφατα μηνύματα", + "@recentMessages": { + "description": "Header for recent messages overlay on map in fullscreen mode" + }, + "addChannel": "Προσθήκη καναλιού", + "@addChannel": { + "description": "Button to add a new channel" + }, + "channelName": "Όνομα καναλιού", + "@channelName": { + "description": "Label for channel name field" + }, + "channelNameHint": "π.χ. Ομάδα Διάσωσης Άλφα", + "@channelNameHint": { + "description": "Hint for channel name field" + }, + "channelSecret": "Μυστικό καναλιού", + "@channelSecret": { + "description": "Label for channel secret field" + }, + "channelSecretHint": "Κοινός κωδικός για αυτό το κανάλι", + "@channelSecretHint": { + "description": "Hint for channel secret field" + }, + "channelSecretHelp": "Αυτό το μυστικό πρέπει να είναι κοινό σε όλα τα μέλη της ομάδας που χρειάζονται πρόσβαση σε αυτό το κανάλι", + "@channelSecretHelp": { + "description": "Help text explaining channel secret" + }, + "channelTypesInfo": "Κανάλια hash (#team): Το μυστικό δημιουργείται αυτόματα από το όνομα. Ίδιο όνομα = ίδιο κανάλι σε όλες τις συσκευές.\n\nΙδιωτικά κανάλια: Χρησιμοποιήστε ρητό μυστικό. Μόνο όσοι το γνωρίζουν μπορούν να συμμετάσχουν.", + "@channelTypesInfo": { + "description": "Information banner explaining hash and private channel types" + }, + "hashChannelInfo": "Κανάλι hash: Το μυστικό θα δημιουργηθεί αυτόματα από το όνομα του καναλιού. Όποιος χρησιμοποιεί το ίδιο όνομα θα μπει στο ίδιο κανάλι.", + "@hashChannelInfo": { + "description": "Help text for hash channels (# prefix)" + }, + "channelNameRequired": "Το όνομα καναλιού είναι υποχρεωτικό", + "@channelNameRequired": { + "description": "Validation error for empty channel name" + }, + "channelNameTooLong": "Το όνομα καναλιού πρέπει να έχει έως 31 χαρακτήρες", + "@channelNameTooLong": { + "description": "Validation error for channel name too long" + }, + "channelSecretRequired": "Το μυστικό καναλιού είναι υποχρεωτικό", + "@channelSecretRequired": { + "description": "Validation error for empty channel secret" + }, + "channelSecretTooLong": "Το μυστικό καναλιού πρέπει να έχει έως 32 χαρακτήρες", + "@channelSecretTooLong": { + "description": "Validation error for channel secret too long" + }, + "invalidAsciiCharacters": "Επιτρέπονται μόνο χαρακτήρες ASCII", + "@invalidAsciiCharacters": { + "description": "Validation error for non-ASCII characters" + }, + "channelCreatedSuccessfully": "Το κανάλι δημιουργήθηκε επιτυχώς", + "@channelCreatedSuccessfully": { + "description": "Success message after creating channel" + }, + "channelCreationFailed": "Αποτυχία δημιουργίας καναλιού: {error}", + "@channelCreationFailed": { + "description": "Error message when channel creation fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deleteChannel": "Διαγραφή καναλιού", + "@deleteChannel": { + "description": "Delete channel button/menu item" + }, + "deleteChannelConfirmation": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το κανάλι \"{channelName}\"; Αυτή η ενέργεια δεν αναιρείται.", + "@deleteChannelConfirmation": { + "description": "Confirmation dialog when deleting a channel", + "placeholders": { + "channelName": { + "type": "String" + } + } + }, + "channelDeletedSuccessfully": "Το κανάλι διαγράφηκε επιτυχώς", + "@channelDeletedSuccessfully": { + "description": "Success message after deleting channel" + }, + "channelDeletionFailed": "Αποτυχία διαγραφής καναλιού: {error}", + "@channelDeletionFailed": { + "description": "Error message when channel deletion fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "createChannel": "Δημιουργία καναλιού", + "@createChannel": { + "description": "Button text for creating a channel" + }, + "wizardBack": "Πίσω", + "@wizardBack": { + "description": "Wizard back button text" + }, + "wizardSkip": "Παράλειψη", + "@wizardSkip": { + "description": "Wizard skip button text" + }, + "wizardNext": "Επόμενο", + "@wizardNext": { + "description": "Wizard next button text" + }, + "wizardGetStarted": "Ξεκινήστε", + "@wizardGetStarted": { + "description": "Wizard final button text to complete onboarding" + }, + "wizardWelcomeTitle": "Καλώς ήρθατε στο MeshCore SAR", + "@wizardWelcomeTitle": { + "description": "Welcome wizard first page title" + }, + "viewWelcomeTutorial": "Προβολή οδηγού καλωσορίσματος", + "@viewWelcomeTutorial": { + "description": "Settings option to re-show welcome wizard" + }, + "allTeamContacts": "Όλες οι επαφές ομάδας", + "@allTeamContacts": { + "description": "Destination option to send SAR marker to all team contacts" + }, + "directMessagesInfo": "Άμεσα μηνύματα με ACK. Στάλθηκαν σε {count} μέλη ομάδας.", + "@directMessagesInfo": { + "description": "Information about sending to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "sarMarkerSentToContacts": "Ο δείκτης SAR στάλθηκε σε {count} επαφές", + "@sarMarkerSentToContacts": { + "description": "Success message after sending SAR marker to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noContactsAvailable": "Δεν υπάρχουν διαθέσιμες επαφές ομάδας", + "@noContactsAvailable": { + "description": "Message when there are no chat contacts to send to" + }, + "reply": "Απάντηση", + "technicalDetails": "Τεχνικές λεπτομέρειες", + "messageTechnicalDetails": "Τεχνικές λεπτομέρειες μηνύματος", + "linkQuality": "Ποιότητα σύνδεσης", + "delivery": "Παράδοση", + "status": "Κατάσταση", + "expectedAckTag": "Αναμενόμενη ετικέτα ACK", + "roundTrip": "Χρόνος μετ' επιστροφής", + "retryAttempt": "Απόπειρα επανάληψης", + "floodFallback": "Εφεδρική πλημμύρα", + "identity": "Ταυτότητα", + "messageId": "Αναγνωριστικό μηνύματος", + "sender": "Αποστολέας", + "senderKey": "Κλειδί αποστολέα", + "recipient": "Παραλήπτης", + "recipientKey": "Κλειδί παραλήπτη", + "voice": "Φωνή", + "voiceId": "Αναγνωριστικό φωνής", + "envelope": "Φάκελος", + "sessionProgress": "Πρόοδος συνεδρίας", + "complete": "Ολοκληρώθηκε", + "rawDump": "Ακατέργαστα δεδομένα", + "cannotRetryMissingRecipient": "Δεν είναι δυνατή η επανάληψη: λείπουν πληροφορίες παραλήπτη", + "voiceUnavailable": "Η φωνή δεν είναι διαθέσιμη αυτή τη στιγμή", + "requestingVoice": "Αίτηση φωνής", + "device": "συσκευή", + "change": "Αλλαγή", + "wizardOverviewDescription": "Αυτή η εφαρμογή συνδυάζει μηνύματα MeshCore, ενημερώσεις πεδίου SAR, χαρτογράφηση και εργαλεία συσκευής σε ένα σημείο.", + "wizardOverviewFeature1": "Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και μηνύματα καναλιού από την κύρια καρτέλα Μηνύματα.", + "wizardOverviewFeature2": "Μοιραστείτε δείκτες SAR, σχεδιάσεις χάρτη, ηχητικά αποσπάσματα και εικόνες μέσω του mesh.", + "wizardOverviewFeature3": "Συνδεθείτε μέσω BLE ή TCP και στη συνέχεια διαχειριστείτε το συνοδευτικό ραδιόφωνο μέσα από την εφαρμογή.", + "wizardMessagingTitle": "Μηνύματα και αναφορές πεδίου", + "wizardMessagingDescription": "Τα μηνύματα εδώ είναι κάτι περισσότερο από απλό κείμενο. Η εφαρμογή υποστηρίζει ήδη πολλαπλά επιχειρησιακά φορτία και ροές μεταφοράς.", + "wizardMessagingFeature1": "Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και κίνηση καναλιού από έναν μόνο συντάκτη.", + "wizardMessagingFeature2": "Δημιουργήστε ενημερώσεις SAR και επαναχρησιμοποιήσιμα πρότυπα SAR για συνηθισμένες αναφορές πεδίου.", + "wizardMessagingFeature3": "Μεταφέρετε φωνητικές συνεδρίες και εικόνες, με ένδειξη προόδου και εκτιμήσεις χρόνου εκπομπής στο περιβάλλον.", + "wizardConnectDeviceTitle": "Σύνδεση συσκευής", + "wizardConnectDeviceDescription": "Συνδέστε το ραδιόφωνο MeshCore, επιλέξτε όνομα και εφαρμόστε μια προρύθμιση ραδιοφώνου πριν συνεχίσετε.", + "wizardSetupBadge": "Ρύθμιση", + "wizardOverviewBadge": "Επισκόπηση", + "wizardConnectedToDevice": "Συνδέθηκε με το {deviceName}", + "wizardNoDeviceConnected": "Δεν έχει συνδεθεί ακόμη συσκευή", + "wizardSkipForNow": "Παράλειψη προς το παρόν", + "wizardDeviceNameLabel": "Όνομα συσκευής", + "wizardDeviceNameHelp": "Αυτό το όνομα ανακοινώνεται σε άλλους χρήστες του MeshCore.", + "wizardConfigRegionLabel": "Περιοχή ρύθμισης", + "wizardConfigRegionHelp": "Χρησιμοποιεί την πλήρη επίσημη λίστα προρυθμίσεων MeshCore. Η προεπιλογή είναι EU/UK (Narrow).", + "wizardPresetNote1": "Βεβαιωθείτε ότι η επιλεγμένη προρύθμιση ταιριάζει με τους τοπικούς ραδιοφωνικούς κανονισμούς.", + "wizardPresetNote2": "Η λίστα αντιστοιχεί στην επίσημη ροή προρυθμίσεων του εργαλείου ρύθμισης MeshCore.", + "wizardPresetNote3": "Το EU/UK (Narrow) παραμένει επιλεγμένο από προεπιλογή κατά την αρχική ρύθμιση.", + "wizardSaving": "Αποθήκευση...", + "wizardSaveAndContinue": "Αποθήκευση και συνέχεια", + "wizardEnterDeviceName": "Εισαγάγετε όνομα συσκευής πριν συνεχίσετε.", + "wizardDeviceSetupSaved": "Αποθηκεύτηκε το {deviceName} με {presetName}.", + "wizardNetworkTitle": "Επαφές, δωμάτια και αναμεταδότες", + "wizardNetworkDescription": "Η καρτέλα Επαφές οργανώνει το δίκτυο που ανακαλύπτετε και τις διαδρομές που μαθαίνετε με τον χρόνο.", + "wizardNetworkFeature1": "Ελέγξτε μέλη ομάδας, αναμεταδότες, δωμάτια, κανάλια και εκκρεμείς ανακοινώσεις σε μία λίστα.", + "wizardNetworkFeature2": "Χρησιμοποιήστε smart ping, σύνδεση σε δωμάτια, μαθημένες διαδρομές και εργαλεία επαναφοράς διαδρομών όταν η συνδεσιμότητα γίνεται χαοτική.", + "wizardNetworkFeature3": "Δημιουργήστε κανάλια και διαχειριστείτε προορισμούς δικτύου χωρίς να φύγετε από την εφαρμογή.", + "wizardMapOpsTitle": "Χάρτης, ίχνη και κοινόχρηστη γεωμετρία", + "wizardMapOpsDescription": "Ο χάρτης της εφαρμογής συνδέεται άμεσα με τα μηνύματα, την παρακολούθηση και τα επικαλύμματα SAR αντί να είναι ξεχωριστός προβολέας.", + "wizardMapOpsFeature1": "Παρακολουθήστε τη θέση σας, τις τοποθεσίες της ομάδας και τα ίχνη κίνησης στον χάρτη.", + "wizardMapOpsFeature2": "Ανοίξτε σχεδιάσεις από μηνύματα, προεπισκοπήστε τες επιτόπου και αφαιρέστε τες από τον χάρτη όταν χρειάζεται.", + "wizardMapOpsFeature3": "Χρησιμοποιήστε προβολές χάρτη αναμεταδοτών και κοινόχρηστα επικαλύμματα για να κατανοήσετε την εμβέλεια του δικτύου στο πεδίο.", + "wizardToolsTitle": "Εργαλεία πέρα από τα μηνύματα", + "wizardToolsDescription": "Υπάρχουν περισσότερα εδώ από τις τέσσερις κύριες καρτέλες. Η εφαρμογή περιλαμβάνει επίσης ρυθμίσεις, διαγνωστικά και προαιρετικές ροές αισθητήρων.", + "wizardToolsFeature1": "Ανοίξτε τη ρύθμιση συσκευής για να αλλάξετε παραμέτρους ραδιοφώνου, τηλεμετρία, ισχύ TX και στοιχεία συνοδευτικής συσκευής.", + "wizardToolsFeature2": "Ενεργοποιήστε την καρτέλα Αισθητήρες όταν θέλετε πίνακες παρακολούθησης και γρήγορες ενέργειες ανανέωσης.", + "wizardToolsFeature3": "Χρησιμοποιήστε καταγραφές πακέτων, σάρωση φάσματος και διαγνωστικά προγραμματιστή κατά την αντιμετώπιση προβλημάτων του mesh.", + "contactInSensors": "Στους αισθητήρες", + "contactAddToSensors": "Προσθήκη στους αισθητήρες", + "contactSetPath": "Ορισμός διαδρομής", + "contactAddedToSensors": "Το {contactName} προστέθηκε στους Αισθητήρες", + "contactFailedToClearRoute": "Αποτυχία εκκαθάρισης διαδρομής: {error}", + "contactRouteCleared": "Η διαδρομή εκκαθαρίστηκε", + "contactRouteSet": "Ορίστηκε διαδρομή: {route}", + "contactFailedToSetRoute": "Αποτυχία ορισμού διαδρομής: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "Λήξη χρόνου ACK", + "opcode": "Opcode", + "payload": "Ωφέλιμο φορτίο", + "hops": "Αναπηδήσεις", + "hashSize": "Μέγεθος hash", + "pathBytes": "Bytes διαδρομής", + "selectedPath": "Επιλεγμένη διαδρομή", + "estimatedTx": "Εκτιμώμενη εκπομπή", + "senderToReceipt": "Αποστολέας σε απόδειξη", + "receivedCopies": "Ληφθέντα αντίγραφα", + "retryCause": "Αιτία επανάληψης", + "retryMode": "Λειτουργία επανάληψης", + "retryResult": "Αποτέλεσμα επανάληψης", + "lastRetry": "Τελευταία επανάληψη", + "rxPackets": "Πακέτα RX", + "mesh": "Mesh", + "rate": "Ρυθμός", + "window": "Παράθυρο", + "posttxDelay": "Καθυστέρηση μετά-tx", + "bandpass": "Ζωνοπερατό", + "bandpassFilterVoice": "Ζωνοπερατό φίλτρο φωνής", + "active": "Ενεργό", + "addContact": "Προσθήκη επαφής", + "all": "Όλα", + "clearAllLabel": "Εκκαθάριση όλων", + "clearFilters": "Εκκαθάριση φίλτρων", + "clearRoute": "Εκκαθάριση διαδρομής", + "clearMessages": "Εκκαθάριση μηνυμάτων", + "clearScale": "Εκκαθάριση κλίμακας", + "clearDiscoveries": "Εκκαθάριση ανακαλύψεων", + "clearOnlineTraceDatabase": "Εκκαθάριση βάσης ιχνών", + "clearAllChannels": "Εκκαθάριση όλων των καναλιών", + "clearAllContacts": "Εκκαθάριση όλων των επαφών", + "clearChannels": "Εκκαθάριση καναλιών", + "clearContacts": "Εκκαθάριση επαφών", + "clearPathOnMaxRetry": "Εκκαθάριση μονοπατιού στο μέγιστο", + "create": "Δημιουργία", + "custom": "Προσαρμοσμένο", + "defaultValue": "Προεπιλογή", + "duplicate": "Αντιγραφή", + "editName": "Επεξεργασία ονόματος", + "open": "Άνοιγμα", + "paste": "Επικόλληση", + "preview": "Προεπισκόπηση", + "remove": "Αφαίρεση", + "rename": "Μετονομασία", + "resolveAll": "Επίλυση όλων", + "send": "Αποστολή", + "sendAnyway": "Αποστολή ούτως ή άλλως", + "share": "Κοινοποίηση", + "trace": "Ίχνος", + "discovery": "Ανακάλυψη", + "discoverRepeaters": "Ανακάλυψη αναμεταδοτών", + "discoverSensors": "Ανακάλυψη αισθητήρων", + "repeaterDiscoverySent": "Αποστολή ανακάλυψης αναμεταδοτών", + "sensorDiscoverySent": "Αποστολή ανακάλυψης αισθητήρων", + "clearedPendingDiscoveries": "Εκκαθάριση εκκρεμών ανακαλύψεων.", + "autoDiscovery": "Αυτόματη ανακάλυψη", + "enableAutomaticAdding": "Ενεργοποίηση αυτόματης προσθήκης", + "autoaddRepeaters": "Αυτόματη προσθήκη αναμεταδοτών", + "autoaddRoomServers": "Αυτόματη προσθήκη διακομιστών δωματίου", + "autoaddSensors": "Αυτόματη προσθήκη αισθητήρων", + "autoaddUsers": "Αυτόματη προσθήκη χρηστών", + "overwriteOldestWhenFull": "Αντικατάσταση παλαιότερων όταν γεμίσει", + "storage": "Αποθήκευση", + "dangerZone": "Ζώνη κινδύνου", + "profiles": "Προφίλ", + "favourites": "Αγαπημένα", + "sensors": "Αισθητήρες", + "others": "Άλλοι", + "gpsModule": "Μονάδα GPS", + "liveTraffic": "Ζωντανή κίνηση", + "repeatersMap": "Χάρτης αναμεταδοτών", + "blePacketLogs": "Αρχεία πακέτων BLE", + "onlineTraceDatabase": "Βάση δεδομένων ιχνών", + "routePathByteSize": "Μέγεθος διαδρομής σε bytes", + "messageNotifications": "Ειδοποιήσεις μηνυμάτων", + "sarAlerts": "Ειδοποιήσεις SAR", + "discoveryNotifications": "Ειδοποιήσεις ανακάλυψης", + "updateNotifications": "Ειδοποιήσεις ενημερώσεων", + "muteWhileAppIsOpen": "Σίγαση με ανοιχτή εφαρμογή", + "disableContacts": "Απενεργοποίηση επαφών", + "enableSensorsTab": "Ενεργοποίηση καρτέλας Αισθητήρων", + "enableProfiles": "Ενεργοποίηση προφίλ", + "nearestRepeaterFallback": "Πλησιέστερος αναμεταδότης ως εφεδρεία", + "deleteAllStoredMessageHistory": "Διαγραφή όλου του ιστορικού", + "messageFontSize": "Μέγεθος γραμματοσειράς μηνυμάτων", + "rotateMapWithHeading": "Περιστροφή χάρτη με κατεύθυνση", + "showMapDebugInfo": "Εμφάνιση πληροφοριών αποσφαλμάτωσης", + "openMapInFullscreen": "Άνοιγμα χάρτη σε πλήρη οθόνη", + "showSarMarkersLabel": "Εμφάνιση δεικτών SAR", + "hideRepeatersOnMap": "Απόκρυψη αναμεταδοτών στον χάρτη", + "setMapScale": "Ρύθμιση κλίμακας χάρτη", + "customMapScaleSaved": "Προσαρμοσμένη κλίμακα χάρτη αποθηκεύτηκε", + "voiceBitrate": "Ρυθμός bit φωνής", + "voiceCompressor": "Συμπιεστής φωνής", + "voiceLimiter": "Περιοριστής φωνής", + "micAutoGain": "Αυτόματη ενίσχυση μικροφώνου", + "echoCancellation": "Ακύρωση ηχούς", + "noiseSuppression": "Καταστολή θορύβου", + "trimSilenceInVoiceMessages": "Περικοπή σιωπής σε φωνητικά μηνύματα", + "compressor": "Συμπιεστής", + "limiter": "Περιοριστής", + "autoGain": "Αυτόματη ενίσχυση", + "echoCancel": "Ηχώ", + "noiseSuppress": "Θόρυβος", + "silenceTrim": "Σιωπή", + "maxImageSize": "Μέγιστο μέγεθος εικόνας", + "imageCompression": "Συμπίεση εικόνας", + "grayscale": "Κλίμακα του γκρι", + "ultraMode": "Λειτουργία ultra", + "fastPrivateGpsUpdates": "Γρήγορες ιδιωτικές ενημερώσεις GPS", + "movementThreshold": "Κατώφλι κίνησης", + "fastGpsMovementThreshold": "Κατώφλι κίνησης γρήγορου GPS", + "fastGpsActiveuseInterval": "Διάστημα ενεργής χρήσης γρήγορου GPS", + "activeuseUpdateInterval": "Διάστημα ενημέρωσης ενεργής χρήσης", + "repeatNearbyTraffic": "Επανάληψη κοντινής κίνησης", + "relayThroughRepeatersAcrossTheMesh": "Αναμετάδοση μέσω αναμεταδοτών στο δίκτυο", + "nearbyOnlyWithoutRepeaterFlooding": "Μόνο κοντά, χωρίς πλημμυρισμό", + "multihop": "Πολλαπλών αναπηδήσεων", + "createProfile": "Δημιουργία προφίλ", + "renameProfile": "Μετονομασία προφίλ", + "newProfile": "Νέο προφίλ", + "manageProfiles": "Διαχείριση προφίλ", + "enableProfilesToStartManagingThem": "Ενεργοποιήστε τα προφίλ για να τα διαχειριστείτε.", + "openMessage": "Άνοιγμα μηνύματος", + "jumpToTheRelatedSarMessage": "Μετάβαση στο σχετικό μήνυμα SAR", + "removeSarMarker": "Αφαίρεση δείκτη SAR", + "pleaseSelectADestinationToSendSarMarker": "Επιλέξτε προορισμό για αποστολή δείκτη SAR", + "sarMarkerBroadcastToPublicChannel": "Δείκτης SAR εκπεμπόμενος στο δημόσιο κανάλι", + "sarMarkerSentToRoom": "Δείκτης SAR εστάλη στο δωμάτιο", + "loadFromGallery": "Φόρτωση από τη γκαλερί", + "replaceImage": "Αντικατάσταση εικόνας", + "selectFromGallery": "Επιλογή από τη γκαλερί", + "team": "Ομάδα", + "found": "Βρέθηκε", + "staging": "Χώρος συγκέντρωσης", + "object": "Αντικείμενο", + "quiet": "Ήσυχο", + "busy": "Απασχολημένο", + "searchMessages": "Αναζήτηση μηνυμάτων", + "sendImageFromGallery": "Αποστολή εικόνας από τη γκαλερί", + "takePhoto": "Λήψη φωτογραφίας", + "allMessages": "Όλα τα μηνύματα", + "sendToPublicChannel": "Αποστολή στο δημόσιο κανάλι;", + "selectMarkerTypeAndDestination": "Επιλέξτε τύπο δείκτη και προορισμό", + "noDestinationsAvailableLabel": "Δεν υπάρχουν διαθέσιμοι προορισμοί", + "image": "Εικόνα", + "format": "Μορφή", + "dimensions": "Διαστάσεις", + "segments": "Τμήματα", + "transfers": "Μεταφορές", + "downloadedBy": "Λήφθηκε από", + "saveDiscoverySettings": "Αποθήκευση ρυθμίσεων ανακάλυψης", + "savePublicInfo": "Αποθήκευση δημόσιων πληροφοριών", + "saveRadioSettings": "Αποθήκευση ρυθμίσεων ραδιοφώνου", + "savePath": "Αποθήκευση διαδρομής", + "wipeDeviceData": "Διαγραφή δεδομένων συσκευής", + "wipeDevice": "Διαγραφή συσκευής", + "destructiveDeviceActions": "Καταστροφικές ενέργειες συσκευής.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Επιλέξτε μια προεπιλογή ή ρυθμίστε τις ρυθμίσεις ραδιοφώνου.", + "used": "Χρησιμοποιήθηκε", + "total": "Σύνολο", + "renameValue": "Μετονομασία τιμής", + "customizeFields": "Προσαρμογή πεδίων", + "livePreview": "Ζωντανή προεπισκόπηση", + "refreshSchedule": "Χρονοδιάγραμμα ανανέωσης", + "noResponse": "Χωρίς απάντηση", + "refreshing": "Ανανέωση", + "unavailable": "Μη διαθέσιμο", + "pickARelayOrNodeToWatchInSensors": "Επιλέξτε ένα relay ή κόμβο για παρακολούθηση.", + "publicKeyLabel": "Δημόσιο κλειδί", + "alreadyInContacts": "Ήδη στις επαφές", + "connectToADeviceBeforeAddingContacts": "Συνδεθείτε σε μια συσκευή πριν προσθέσετε επαφές", + "fromContacts": "Από τις επαφές", + "onlineOnly": "Μόνο συνδεδεμένοι", + "inBoth": "Σε αμφότερα", + "source": "Πηγή", + "allMessagesCleared": "Όλα τα μηνύματα διαγράφηκαν", + "onlineTraceDatabaseCleared": "Βάση δεδομένων ιχνών εκκαθαρίστηκε", + "packetLogsCleared": "Τα αρχεία πακέτων εκκαθαρίστηκαν", + "hexDataCopiedToClipboard": "Τα δεδομένα hex αντιγράφηκαν", + "developerModeEnabled": "Λειτουργία προγραμματιστή ενεργοποιήθηκε", + "developerModeDisabled": "Λειτουργία προγραμματιστή απενεργοποιήθηκε", + "clipboardIsEmpty": "Το πρόχειρο είναι κενό", + "contactImported": "Επαφή εισήχθη", + "contactLinkCopiedToClipboard": "Ο σύνδεσμος επαφής αντιγράφηκε", + "failedToExportContact": "Αποτυχία εξαγωγής επαφής", + "noLogsToExport": "Δεν υπάρχουν αρχεία για εξαγωγή", + "exportAsCsv": "Εξαγωγή ως CSV", + "exportAsText": "Εξαγωγή ως κείμενο", + "receivedRfc3339": "Ελήφθη (RFC3339)", + "buildTime": "Ώρα κατασκευής", + "downloadUrlNotAvailable": "Η URL λήψης δεν είναι διαθέσιμη", + "cannotOpenDownloadUrl": "Αδυναμία ανοίγματος URL λήψης", + "updateCheckIsOnlyAvailableOnAndroid": "Ο έλεγχος ενημερώσεων είναι διαθέσιμος μόνο σε Android", + "youAreRunningTheLatestVersion": "Χρησιμοποιείτε την πιο πρόσφατη έκδοση", + "updateAvailableButDownloadUrlNotFound": "Διαθέσιμη ενημέρωση αλλά η URL λήψης δεν βρέθηκε", + "startTictactoe": "Εκκίνηση Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe μη διαθέσιμο", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: άγνωστος αντίπαλος", + "tictactoeWaitingForStart": "Tic-Tac-Toe: αναμονή για εκκίνηση", + "acceptsShareLinks": "Αποδέχεται κοινόχρηστους συνδέσμους", + "supportsRawHex": "Υποστηρίζει ακατέργαστο hex", + "clipboardfriendly": "Φιλικό προς πρόχειρο", + "captured": "Καταγράφηκε", + "size": "Μέγεθος", + "noCustomChannelsToClear": "Δεν υπάρχουν προσαρμοσμένα κανάλια.", + "noDeviceContactsToClear": "Δεν υπάρχουν επαφές συσκευής.", + "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 (λειτουργία 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 (λειτουργία 2)", + "trafficStatsMessageHistoryAndDeveloperTools": "Στατιστικά κίνησης, ιστορικό μηνυμάτων και εργαλεία προγραμματιστή", + "twoBytesMode1": "2 bytes (λειτουργία 1)", + "twoMinutes": "2 λεπτά", + "undo": "Αναίρεση", + "updated": "Ενημερώθηκε", + "useContactFlags": "Χρήση σημαιών επαφής", + "useSavedCoordinates": "Χρήση αποθηκευμένων συντεταγμένων", + "view": "Προβολή", + "viewNeighbours": "Προβολή γειτόνων", + "viewPublicStats": "Προβολή δημόσιων στατιστικών", + "setRegionScope": "Ορισμός εύρους περιοχής", + "regionScope": "Εύρος περιοχής", + "regionScopeNone": "Κανένα (καθολικό)", + "clearRegionScope": "Εκκαθάριση εύρους", + "regionScopeWarning": "Μόνο οι αναμεταδότες που επιτρέπουν αυτή την περιοχή θα προωθήσουν.", + "discoverRegions": "Ανακάλυψη από αναμεταδότες", + "discoveringRegions": "Ανακάλυψη περιοχών...", + "enterRegionName": "Όνομα περιοχής (π.χ. auckland)", + "noRegionsFound": "Δεν βρέθηκαν περιοχές σε αυτόν τον αναμεταδότη.", + "regionScopeSet": "Εύρος περιοχής ορίστηκε σε {name}", + "regionScopeCleared": "Εύρος περιοχής εκκαθαρίστηκε", + "simpleMode": "Απλή λειτουργία", + "simpleModeDescription": "Μόνο συνομιλία και χάρτης με αγαπημένες επαφές", + "simpleModeRequiresLockedDestination": "Ενεργοποιήστε πρώτα το «Κλείδωμα προορισμού» με ένα κανάλι για να χρησιμοποιήσετε την Απλή λειτουργία", + "noFavouriteContacts": "Καμία αγαπημένη επαφή", + "selectDestinationFirst": "Επιλέξτε πρώτα κανάλι, επαφή ή δωμάτιο", + "recipientNoLongerAvailable": "Ο παραλήπτης δεν είναι πλέον διαθέσιμος", + "failedToSendMessage": "Αποτυχία αποστολής: {error}", + "tictactoeDirectOnly": "Η τρίλιζα λειτουργεί μόνο σε άμεσα μηνύματα. Επιλέξτε πρώτα μια επαφή.", + "deviceKeyUnavailable": "Το κλειδί της συσκευής δεν είναι διαθέσιμο", + "imageCompressionFailed": "Αποτυχία συμπίεσης εικόνας", + "imageFragmentationFailed": "Αποτυχία κατακερματισμού εικόνας", + "failedToAnnounceImage": "Αποτυχία ανακοίνωσης εικόνας", + "noRecipientSelected": "Δεν επιλέχθηκε παραλήπτης", + "imageSendFailed": "Αποτυχία αποστολής εικόνας", + "microphonePermissionRequiredForVoice": "Απαιτείται άδεια μικροφώνου για φωνητικά μηνύματα", + "recordingDiscarded": "Η ηχογράφηση απορρίφθηκε", + "publicChannelMediaWarning": "Πρόκειται να στείλετε {mediaType} στο δημόσιο κανάλι. Δεν συνιστάται, καθώς μπορεί να το λάβουν όλοι στο δίκτυο mesh. Επιλέξτε ιδιωτικό ή επισημασμένο κανάλι, εκτός αν αυτό θέλετε.", + "selectPrivateChannelToShareLocation": "Επιλέξτε ιδιωτικό κανάλι για να μοιραστείτε την τοποθεσία σας.", + "stopSharingMyLocation": "Διακοπή κοινοποίησης της τοποθεσίας μου", + "shareMyLocation": "Κοινοποίηση της τοποθεσίας μου", + "stopRecording": "Διακοπή ηχογράφησης", + "recordVoice": "Ηχογράφηση φωνητικού μηνύματος", + "moreActions": "Περισσότερες ενέργειες", + "noMessagesInFilter": "Δεν υπάρχουν μηνύματα σε αυτό το φίλτρο", + "noMatchesInFilter": "Δεν υπάρχουν αποτελέσματα σε αυτό το φίλτρο", + "syncFailed": "Αποτυχία συγχρονισμού: {error}", + "invalidLink": "Μη έγκυρος σύνδεσμος", + "cannotOpenLink": "Δεν είναι δυνατό το άνοιγμα του συνδέσμου", + "failedToOpenLink": "Αποτυχία ανοίγματος του συνδέσμου", + "failedToResendMessage": "Αποτυχία επαναποστολής του μηνύματος", + "retryFailed": "Η επανάληψη απέτυχε: {error}", + "deleteDrawingAndMessage": "Διαγραφή σχεδίου και μηνύματος", + "lastEchoRelay": "Τελευταία ηχώ: αναμεταδότης", + "lastEchoPath": "Τελευταία ηχώ: διαδρομή", + "lastEchoBytesReport": "Τελευταία ηχώ: αναφορά byte", + "noGpsCoordinatesAvailable": "Δεν υπάρχουν διαθέσιμες συντεταγμένες GPS", + "notASarMarker": "Δεν είναι δείκτης SAR", + "failedToSaveTemplate": "Αποτυχία αποθήκευσης προτύπου: {error}", + "drawingNotFound": "Το σχέδιο δεν βρέθηκε", + "unknownDrawingType": "Άγνωστος τύπος σχεδίου", + "customMapMarker": "Προσαρμοσμένος δείκτης χάρτη", + "customMapPointLabel": "Σημείο: {point}", + "mapIdLabel": "ID χάρτη: {id}", + "cannotFetchVoice": "Δεν είναι δυνατή η λήψη της φωνής", + "senderContactUnknown": "Η επαφή του αποστολέα είναι άγνωστη. Συγχρονίστε πρώτα τις επαφές.", + "senderRouteUnknown": "Η διαδρομή του αποστολέα είναι άγνωστη. Συγχρονίστε πρώτα επαφές/διαδρομή.", + "messageTooFar": "Το μήνυμα είναι πολύ μακριά ({hops} αναπηδήσεις, μέγ. {maxHops}).", + "senderRouteNoPathResponse": "Η διαδρομή του αποστολέα δεν απάντησε στον έλεγχο διαδρομής. Συγχρονίστε επαφές/διαδρομή και δοκιμάστε ξανά.", + "senderRouteNoRawResponse": "Η διαδρομή του αποστολέα δεν απάντησε στη raw διαδρομή μεταφοράς.", + "voiceFetchOverHops": "Η λήψη φωνής μέσω {hops} αναπηδήσεων μπορεί να διαρκέσει.", + "voiceReceiveCanceled": "Η λήψη φωνής ακυρώθηκε", + "fetchingMissingVoiceFragments": "Λήψη φωνητικών τμημάτων που λείπουν", + "receivingVoice": "Λήψη φωνής", + "cannotFetchImage": "Δεν είναι δυνατή η λήψη της εικόνας", + "imageFetchOverHops": "Η λήψη εικόνας μέσω {hops} αναπηδήσεων μπορεί να διαρκέσει.", + "imageAlreadyBeingReceived": "Η εικόνα λαμβάνεται ήδη", + "loadImage": "Φόρτωση εικόνας", + "imageFetchFailedToSendRequest": "Αποτυχία αποστολής αιτήματος εικόνας", + "imageUnavailable": "Η εικόνα δεν είναι διαθέσιμη αυτήν τη στιγμή", + "imageFetchTimedOut": "Η λήψη της εικόνας έληξε", + "imageReceiveCanceled": "Η λήψη της εικόνας ακυρώθηκε", + "closeImagePreview": "Κλείσιμο προεπισκόπησης εικόνας", + "recordingReleaseToSend": "Ηχογράφηση... αφήστε για αποστολή", + "sendingVoice": "Αποστολή φωνής...", + "sendLongPressToRecordVoice": "Αποστολή (παρατεταμένο πάτημα για ηχογράφηση)", + "discardRecording": "Απόρριψη ηχογράφησης", + "hopCount": "{count, plural, one{{count} αναπήδηση} other{{count} αναπηδήσεις}}", + "channelsSectionDescription": "Κανάλια εκπομπής για κοντινή κίνηση mesh", + "roomsSectionDescription": "Κοινόχρηστοι χώροι για συνεχή συντονισμό ομάδας", + "contactsSectionDescription": "Άτομα και συσκευές που μπορείτε να προσεγγίσετε άμεσα", + "noRecentChatters": "Κανένας πρόσφατος συνομιλητής", + "public": "Δημόσιο", + "failedToSendTicTacToeMove": "Αποτυχία αποστολής κίνησης τρίλιζας", + "youWon": "Κερδίσατε", + "opponentWon": "Κέρδισε ο αντίπαλος", + "gameDraw": "Ισοπαλία", + "yourTurn": "Η σειρά σας", + "opponentTurn": "Σειρά του αντιπάλου", + "pleaseEnterValidCoordinates": "Εισαγάγετε έγκυρες συντεταγμένες", + "locationNotAvailable": "Η τοποθεσία δεν είναι διαθέσιμη", + "customCaveMapPoint": "Προσαρμοσμένο σημείο χάρτη σπηλαίου", + "mapPoint": "Σημείο χάρτη", + "fetchingMissingImageFragments": "Λήψη τμημάτων που λείπουν…", + "loadingImage": "Φόρτωση…", + "receivingImage": "Λήψη…", + "tapToLoad": "Πατήστε για φόρτωση", + "connectDevice": "Σύνδεση συσκευής", + "chooseTransportSubtitle": "Επιλέξτε μεταφορά Bluetooth, WiFi ή σειριακή", + "ble": "BLE", + "network": "Δίκτυο", + "serial": "Σειριακή θύρα", + "scan": "Σάρωση", + "pressScanToSearchForDevices": "Πατήστε «Σάρωση» για αναζήτηση κοντινών συσκευών", + "failedToConnectToDevice": "Αποτυχία σύνδεσης με {name}", + "unknownDevice": "Άγνωστη συσκευή", + "showingCachedResultsTapRefresh": "Εμφανίζονται αποτελέσματα από την προσωρινή μνήμη. Πατήστε ανανέωση για νέα σάρωση.", + "scanningLocalNetworkOnPort": "Σάρωση τοπικού δικτύου για συσκευές MeshCore WiFi στη θύρα {port}", + "cancelScanAndAddServer": "Ακύρωση σάρωσης και προσθήκη διακομιστή", + "addServer": "Προσθήκη διακομιστή", + "scanningProgressIps": "Σάρωση... {scanned}/{total} διευθύνσεις IP", + "cancelAndEnterManually": "Ακύρωση και χειροκίνητη εισαγωγή", + "noRecentOrDiscoveredServers": "Δεν υπάρχουν ακόμη πρόσφατοι ή εντοπισμένοι διακομιστές", + "recentlyUsed": "Πρόσφατα χρησιμοποιημένοι", + "connecting": "Γίνεται σύνδεση...", + "discoveredOnThisNetwork": "Εντοπίστηκαν σε αυτό το δίκτυο", + "serverNoLongerAvailable": "Ο διακομιστής στο {host}:{port} δεν είναι πλέον διαθέσιμος. Σαρώστε ξανά για να βρείτε ενεργούς διακομιστές.", + "failedToConnectToHost": "Αποτυχία σύνδεσης με {host}:{port}", + "enterValidIpAddress": "Εισαγάγετε έγκυρη διεύθυνση IP", + "enterValidTcpPort": "Εισαγάγετε έγκυρη θύρα TCP", + "ipAddress": "Διεύθυνση IP", + "tcpPort": "Θύρα TCP", + "customServerPort": "Προσαρμοσμένη θύρα διακομιστή", + "chat": "Συνομιλία", + "restoringPreviousLink": "Επαναφορά προηγούμενης σύνδεσης", + "noDeviceConnected": "Δεν υπάρχει συνδεδεμένη συσκευή", + "sendAdvert": "Αποστολή της επαφής μου", + "advertMode": "Αποστολή της επαφής μου", + "advertModeSubtitle": "Επιλέξτε πόσο μακριά θα φτάσουν τα στοιχεία επαφής σας.", + "floodAdvertSent": "Η επαφή στάλθηκε σε όλο το δίκτυο mesh", + "directAdvertSent": "Η επαφή στάλθηκε σε κοντινές συσκευές" +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..f40c235 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,3447 @@ +{ + "@@locale": "en", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "The application title" + }, + "messages": "Messages", + "@messages": { + "description": "Messages tab label" + }, + "contacts": "Contacts", + "@contacts": { + "description": "Contacts tab label" + }, + "map": "Map", + "@map": { + "description": "Map tab label" + }, + "settings": "Settings", + "@settings": { + "description": "Settings screen title" + }, + "connect": "Connect", + "@connect": { + "description": "Connect button label" + }, + "disconnect": "Disconnect", + "@disconnect": { + "description": "Disconnect button label" + }, + "noDevicesFound": "No devices found", + "@noDevicesFound": { + "description": "Text shown when no BLE devices are found" + }, + "scanAgain": "Scan Again", + "@scanAgain": { + "description": "Button to restart BLE scanning" + }, + "deviceNotConnected": "Device not connected", + "@deviceNotConnected": { + "description": "Error message when device is not connected" + }, + "locationPermissionDenied": "Location permission denied", + "@locationPermissionDenied": { + "description": "Error when location permission is denied" + }, + "locationPermissionPermanentlyDenied": "Location permission permanently denied. Please enable in Settings.", + "@locationPermissionPermanentlyDenied": { + "description": "Error when location permission is permanently denied" + }, + "locationPermissionRequired": "Location permission is required for GPS tracking and team coordination. You can enable it later in Settings.", + "@locationPermissionRequired": { + "description": "Message when location permission is needed" + }, + "locationServicesDisabled": "Location services are disabled. Please enable them in Settings.", + "@locationServicesDisabled": { + "description": "Error when location services are disabled" + }, + "failedToGetGpsLocation": "Failed to get GPS location", + "@failedToGetGpsLocation": { + "description": "Error when GPS location cannot be obtained" + }, + "failedToAdvertise": "Failed to advertise: {error}", + "@failedToAdvertise": { + "description": "Error message for failed advertisement", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "Cancel reconnection", + "@cancelReconnection": { + "description": "Tooltip for cancel reconnection button" + }, + "general": "General", + "@general": { + "description": "General settings section header" + }, + "theme": "Theme", + "@theme": { + "description": "Theme setting label" + }, + "chooseTheme": "Choose Theme", + "@chooseTheme": { + "description": "Theme selection dialog title" + }, + "light": "Light", + "@light": { + "description": "Light theme option" + }, + "dark": "Dark", + "@dark": { + "description": "Dark theme option" + }, + "blueLightTheme": "Blue light theme", + "@blueLightTheme": { + "description": "Description for blue light theme" + }, + "blueDarkTheme": "Blue dark theme", + "@blueDarkTheme": { + "description": "Description for blue dark theme" + }, + "sarRed": "SAR Red", + "@sarRed": { + "description": "SAR Red theme option" + }, + "alertEmergencyMode": "Alert/Emergency mode", + "@alertEmergencyMode": { + "description": "Description for SAR Red theme" + }, + "sarGreen": "SAR Green", + "@sarGreen": { + "description": "SAR Green theme option" + }, + "safeAllClearMode": "Safe/All Clear mode", + "@safeAllClearMode": { + "description": "Description for SAR Green theme" + }, + "autoSystem": "Auto (System)", + "@autoSystem": { + "description": "Auto/System theme option" + }, + "followSystemTheme": "Follow system theme", + "@followSystemTheme": { + "description": "Description for system theme" + }, + "showRxTxIndicators": "Show RX/TX Indicators", + "@showRxTxIndicators": { + "description": "Setting to show RX/TX indicators" + }, + "disableMap": "Disable Map", + "@disableMap": { + "description": "Setting to disable the map tab" + }, + "language": "Language", + "@language": { + "description": "Language setting label" + }, + "chooseLanguage": "Choose Language", + "@chooseLanguage": { + "description": "Language selection dialog title" + }, + "save": "Save", + "@save": { + "description": "Save button label" + }, + "cancel": "Cancel", + "@cancel": { + "description": "Cancel button label" + }, + "close": "Close", + "@close": { + "description": "Close button label" + }, + "about": "About", + "@about": { + "description": "About section header" + }, + "appVersion": "App Version", + "@appVersion": { + "description": "App version label" + }, + "aboutMeshCoreSar": "About MeshCore SAR", + "@aboutMeshCoreSar": { + "description": "About dialog title" + }, + "aboutDescription": "A Search & Rescue application designed for emergency response teams. Features include:\n\n• BLE mesh networking for device-to-device communication\n• Offline maps with multiple layer options\n• Real-time team member tracking\n• SAR tactical markers (found person, fire, staging)\n• Contact management and messaging\n• GPS tracking with compass heading\n• Map tile caching for offline use", + "@aboutDescription": { + "description": "About dialog description" + }, + "technologiesUsed": "Technologies Used:", + "@technologiesUsed": { + "description": "Technologies used section title" + }, + "technologiesList": "• Flutter for cross-platform development\n• BLE (Bluetooth Low Energy) for mesh networking\n• OpenStreetMap for mapping\n• Provider for state management\n• SharedPreferences for local storage", + "@technologiesList": { + "description": "List of technologies used" + }, + "moreInfo": "More Info", + "@moreInfo": { + "description": "More info button label" + }, + "packageName": "Package Name", + "@packageName": { + "description": "Package name label" + }, + "sampleData": "Sample Data", + "@sampleData": { + "description": "Sample data section header" + }, + "sampleDataDescription": "Load or clear sample contacts, channel messages, and SAR markers for testing", + "@sampleDataDescription": { + "description": "Sample data section description" + }, + "loadSampleData": "Load Sample Data", + "@loadSampleData": { + "description": "Load sample data button" + }, + "clearAllData": "Clear All Data", + "@clearAllData": { + "description": "Clear all data button" + }, + "clearAllDataConfirmTitle": "Clear All Data", + "@clearAllDataConfirmTitle": { + "description": "Clear data confirmation dialog title" + }, + "clearAllDataConfirmMessage": "This will clear all contacts and SAR markers. Are you sure?", + "@clearAllDataConfirmMessage": { + "description": "Clear data confirmation message" + }, + "clear": "Clear", + "@clear": { + "description": "Clear button label" + }, + "loadedSampleData": "Loaded {teamCount} team members, {channelCount} channels, {sarCount} SAR markers, {messageCount} messages", + "@loadedSampleData": { + "description": "Success message after loading sample data", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "Failed to load sample data: {error}", + "@failedToLoadSampleData": { + "description": "Error message when sample data fails to load", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "All data cleared", + "@allDataCleared": { + "description": "Success message after clearing all data" + }, + "failedToStartBackgroundTracking": "Failed to start background tracking. Check permissions and BLE connection.", + "@failedToStartBackgroundTracking": { + "description": "Error message when background tracking fails to start" + }, + "locationBroadcast": "Location broadcast: {latitude}, {longitude}", + "@locationBroadcast": { + "description": "Success message for location broadcast", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings.", + "@defaultPinInfo": { + "description": "Information about default PIN for pairing" + }, + "noMessagesYet": "No messages yet", + "@noMessagesYet": { + "description": "Empty state message when there are no messages" + }, + "pullDownToSync": "Pull down to sync messages", + "@pullDownToSync": { + "description": "Instruction to pull down to refresh messages" + }, + "deleteContact": "Delete Contact", + "@deleteContact": { + "description": "Delete contact action label" + }, + "delete": "Delete", + "@delete": { + "description": "Delete button label" + }, + "viewOnMap": "View on Map", + "@viewOnMap": { + "description": "Action to view contact location on map" + }, + "refresh": "Refresh", + "@refresh": { + "description": "Refresh button label" + }, + "resetPath": "Reset Path (Re-route)", + "@resetPath": { + "description": "Action to reset contact path for re-routing" + }, + "copiedToClipboard": "{label} copied to clipboard", + "@copiedToClipboard": { + "description": "Success message when a value is copied to clipboard", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "Please enter a password", + "@pleaseEnterPassword": { + "description": "Validation message for empty password field" + }, + "failedToSyncContacts": "Failed to sync contacts: {error}", + "@failedToSyncContacts": { + "description": "Error message when contact sync fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "Logged in successfully! Waiting for room messages...", + "@loggedInSuccessfully": { + "description": "Success message after successful room login" + }, + "loginFailed": "Login failed - incorrect password", + "@loginFailed": { + "description": "Error message when room login fails" + }, + "loggingIn": "Logging in to {roomName}...", + "@loggingIn": { + "description": "Status message during room login process", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "Failed to send login: {error}", + "@failedToSendLogin": { + "description": "Error message when login command fails to send", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "Low Location Accuracy", + "@lowLocationAccuracy": { + "description": "Warning title for low GPS accuracy" + }, + "continue_": "Continue", + "@continue_": { + "description": "Continue button label" + }, + "sendSarMarker": "Send SAR marker", + "@sendSarMarker": { + "description": "Action to send SAR marker" + }, + "deleteDrawing": "Delete Drawing", + "@deleteDrawing": { + "description": "Action to delete a map drawing" + }, + "drawingTools": "Drawing Tools", + "@drawingTools": { + "description": "Drawing tools section or menu title" + }, + "drawLine": "Draw Line", + "@drawLine": { + "description": "Map drawing mode: line" + }, + "drawLineDesc": "Draw a freehand line on the map", + "@drawLineDesc": { + "description": "Description for line drawing mode" + }, + "drawRectangle": "Draw Rectangle", + "@drawRectangle": { + "description": "Map drawing mode: rectangle" + }, + "drawRectangleDesc": "Draw a rectangular area on the map", + "@drawRectangleDesc": { + "description": "Description for rectangle drawing mode" + }, + "measureDistance": "Measure Distance", + "@measureDistance": { + "description": "Map drawing mode: measure distance" + }, + "measureDistanceDesc": "Long press two points to measure", + "@measureDistanceDesc": { + "description": "Description for distance measurement mode" + }, + "clearMeasurement": "Clear Measurement", + "@clearMeasurement": { + "description": "Tooltip to clear measurement" + }, + "distanceLabel": "Distance: {distance}", + "@distanceLabel": { + "description": "Label showing measured distance", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "Long press for second point", + "@longPressForSecondPoint": { + "description": "Instruction when first measurement point is set" + }, + "longPressToStartMeasurement": "Long press to set first point", + "@longPressToStartMeasurement": { + "description": "Instruction to start measurement" + }, + "longPressToStartNewMeasurement": "Long press to start new measurement", + "@longPressToStartNewMeasurement": { + "description": "Instruction to restart measurement after completion" + }, + "shareDrawings": "Share Drawings", + "@shareDrawings": { + "description": "Action to share drawings to network" + }, + "clearAllDrawings": "Clear All Drawings", + "@clearAllDrawings": { + "description": "Action to clear all local drawings" + }, + "completeLine": "Complete Line", + "@completeLine": { + "description": "Tooltip to complete drawing a line" + }, + "broadcastDrawingsToTeam": "Broadcast {count} drawing{plural} to team", + "@broadcastDrawingsToTeam": { + "description": "Subtitle showing how many drawings will be broadcast", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "Remove all {count} drawing{plural}", + "@removeAllDrawings": { + "description": "Subtitle for remove all drawings action", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "Delete all {count} drawing{plural} from the map?", + "@deleteAllDrawingsConfirm": { + "description": "Confirmation dialog message for deleting all drawings", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "Drawing", + "@drawing": { + "description": "Generic drawing label" + }, + "shareDrawingsCount": "Share {count} Drawing{plural}", + "@shareDrawingsCount": { + "description": "Title for share drawings dialog", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "Show Received Drawings", + "@showReceivedDrawings": { + "description": "Toggle to show/hide received drawings from other team members" + }, + "showingAllDrawings": "Showing all drawings", + "@showingAllDrawings": { + "description": "Subtitle when received drawings are visible" + }, + "showingOnlyYourDrawings": "Showing only your drawings", + "@showingOnlyYourDrawings": { + "description": "Subtitle when received drawings are hidden" + }, + "showSarMarkers": "Show SAR Markers", + "@showSarMarkers": { + "description": "Toggle to show/hide SAR markers on map" + }, + "showingSarMarkers": "Showing SAR markers", + "@showingSarMarkers": { + "description": "Subtitle when SAR markers are visible" + }, + "hidingSarMarkers": "Hiding SAR markers", + "@hidingSarMarkers": { + "description": "Subtitle when SAR markers are hidden" + }, + "clearAll": "Clear All", + "@clearAll": { + "description": "Clear all button label" + }, + "publicChannel": "Public Channel", + "@publicChannel": { + "description": "Public channel option for sharing" + }, + "broadcastToAll": "Broadcast to all nearby nodes (ephemeral)", + "@broadcastToAll": { + "description": "Description for public channel broadcast" + }, + "storedPermanently": "Stored permanently in room", + "@storedPermanently": { + "description": "Description for room storage permanence" + }, + "notConnectedToDevice": "Not connected to device", + "@notConnectedToDevice": { + "description": "Error message when device is not connected for direct messaging" + }, + "typeYourMessage": "Type your message...", + "@typeYourMessage": { + "description": "Placeholder text for message input field" + }, + "quickLocationMarker": "Quick location marker", + "@quickLocationMarker": { + "description": "Subtitle for SAR marker sheet header" + }, + "markerType": "Marker Type", + "@markerType": { + "description": "Label for marker type selection section" + }, + "sendTo": "Send To", + "@sendTo": { + "description": "Label for destination selection section" + }, + "noDestinationsAvailable": "No destinations available.", + "@noDestinationsAvailable": { + "description": "Warning when no rooms or channels exist" + }, + "selectDestination": "Select destination...", + "@selectDestination": { + "description": "Placeholder for destination dropdown" + }, + "ephemeralBroadcastInfo": "Ephemeral: Broadcast over-the-air only. Not stored - nodes must be online.", + "@ephemeralBroadcastInfo": { + "description": "Information about ephemeral channel broadcasts" + }, + "persistentRoomInfo": "Persistent: Stored immutably in room. Synced automatically and preserved offline.", + "@persistentRoomInfo": { + "description": "Information about persistent room storage" + }, + "location": "Location", + "@location": { + "description": "Label for location section" + }, + "fromMap": "From Map", + "@fromMap": { + "description": "Badge showing location is from map tap" + }, + "gettingLocation": "Getting location...", + "@gettingLocation": { + "description": "Loading message while fetching GPS location" + }, + "locationError": "Location Error", + "@locationError": { + "description": "Title for location error messages" + }, + "retry": "Retry", + "@retry": { + "description": "Retry button label" + }, + "refreshLocation": "Refresh location", + "@refreshLocation": { + "description": "Tooltip for refresh location button" + }, + "accuracyMeters": "Accuracy: ±{accuracy}m", + "@accuracyMeters": { + "description": "Display of GPS accuracy in meters", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "Notes (optional)", + "@notesOptional": { + "description": "Label for optional notes field" + }, + "addAdditionalInformation": "Add additional information...", + "@addAdditionalInformation": { + "description": "Placeholder for notes field" + }, + "lowAccuracyWarning": "Location accuracy is ±{accuracy}m. This may not be accurate enough for SAR operations.\n\nContinue anyway?", + "@lowAccuracyWarning": { + "description": "Warning dialog content for low GPS accuracy", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "Login to Room", + "@loginToRoom": { + "description": "Title for room login dialog" + }, + "enterPasswordInfo": "Enter the password to access this room. The password will be saved for future use.", + "@enterPasswordInfo": { + "description": "Information about room password" + }, + "password": "Password", + "@password": { + "description": "Password field label" + }, + "enterRoomPassword": "Enter room password", + "@enterRoomPassword": { + "description": "Password field hint" + }, + "loggingInDots": "Logging in...", + "@loggingInDots": { + "description": "Button text while logging in" + }, + "login": "Login", + "@login": { + "description": "Login button label" + }, + "failedToAddRoom": "Failed to add room to device: {error}\n\nThe room may not have advertised yet.\nTry waiting for the room to broadcast.", + "@failedToAddRoom": { + "description": "Error message when adding room fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "Direct", + "@direct": { + "description": "Direct routing indicator" + }, + "flood": "Flood", + "@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" + }, + "noGpsData": "No GPS data", + "@noGpsData": { + "description": "Message when GPS data is not available" + }, + "distance": "Distance", + "@distance": { + "description": "Distance label" + }, + "directPingTimeout": "Direct ping timeout - retrying {name} with flooding...", + "@directPingTimeout": { + "description": "Warning when direct ping times out", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "Ping failed to {name} - no response received", + "@pingFailed": { + "description": "Error message when ping fails", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "Are you sure you want to delete \"{name}\"?\n\nThis will remove the contact from both the app and the companion radio device.", + "@deleteContactConfirmation": { + "description": "Confirmation message for deleting contact", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "Failed to remove contact: {error}", + "@failedToRemoveContact": { + "description": "Error message when contact removal fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "Type", + "@type": { + "description": "Contact type label" + }, + "publicKey": "Public Key", + "@publicKey": { + "description": "Public key label" + }, + "lastSeen": "Last Seen", + "@lastSeen": { + "description": "Last seen label" + }, + "yes": "Yes", + "@yes": { + "description": "Yes answer" + }, + "no": "No", + "@no": { + "description": "No answer" + }, + "permissions": "Permissions", + "@permissions": { + "description": "Permissions label" + }, + "telemetry": "Telemetry", + "@telemetry": { + "description": "Telemetry section header" + }, + "voltage": "Voltage", + "@voltage": { + "description": "Voltage label" + }, + "battery": "Battery", + "@battery": { + "description": "Battery label" + }, + "temperature": "Temperature", + "@temperature": { + "description": "Temperature label" + }, + "humidity": "Humidity", + "@humidity": { + "description": "Humidity label" + }, + "pressure": "Pressure", + "@pressure": { + "description": "Pressure label" + }, + "gpsTelemetry": "GPS (Telemetry)", + "@gpsTelemetry": { + "description": "GPS from telemetry label" + }, + "reLoginToRoom": "Re-Login to Room", + "@reLoginToRoom": { + "description": "Button to re-login to room" + }, + "heading": "Heading", + "@heading": { + "description": "Compass heading label" + }, + "elevation": "Elevation", + "@elevation": { + "description": "Elevation/altitude label" + }, + "accuracy": "Accuracy", + "@accuracy": { + "description": "GPS accuracy label" + }, + "bearing": "Bearing", + "@bearing": { + "description": "Bearing label in compass" + }, + "direction": "Direction", + "@direction": { + "description": "Direction label in compass" + }, + "filterMarkers": "Filter Markers", + "@filterMarkers": { + "description": "Title for filter markers dialog" + }, + "filterMarkersTooltip": "Filter markers", + "@filterMarkersTooltip": { + "description": "Tooltip for filter button" + }, + "contactsFilter": "Contacts", + "@contactsFilter": { + "description": "Filter option for contacts" + }, + "repeatersFilter": "Repeaters", + "@repeatersFilter": { + "description": "Filter option for repeaters" + }, + "sarMarkers": "SAR Markers", + "@sarMarkers": { + "description": "SAR markers section header" + }, + "foundPerson": "Found Person", + "@foundPerson": { + "description": "Found person SAR marker type" + }, + "fire": "Fire", + "@fire": { + "description": "Fire SAR marker type" + }, + "stagingArea": "Staging Area", + "@stagingArea": { + "description": "Staging area SAR marker type" + }, + "showAll": "Show All", + "@showAll": { + "description": "Button to show all filters" + }, + "locationUnavailable": "Location unavailable", + "@locationUnavailable": { + "description": "Message when GPS location is unavailable" + }, + "ahead": "ahead", + "@ahead": { + "description": "Relative bearing direction - ahead" + }, + "degreesRight": "{degrees}° right", + "@degreesRight": { + "description": "Relative bearing direction - right", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "{degrees}° left", + "@degreesLeft": { + "description": "Relative bearing direction - left", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "Lat: {latitude} Lon: {longitude}", + "@latLonFormat": { + "description": "Latitude and longitude display format", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "No contacts yet", + "@noContactsYet": { + "description": "Empty state message when there are no contacts" + }, + "connectToDeviceToLoadContacts": "Connect to a device to load contacts", + "@connectToDeviceToLoadContacts": { + "description": "Instruction to connect device to load contacts" + }, + "teamMembers": "Team Members", + "@teamMembers": { + "description": "Section header for team members (chat contacts)" + }, + "repeaters": "Repeaters", + "@repeaters": { + "description": "Section header for repeater nodes" + }, + "rooms": "Rooms", + "@rooms": { + "description": "Section header for rooms" + }, + "channels": "Channels", + "@channels": { + "description": "Section header for broadcast channels" + }, + "selectMapLayer": "Select Map Layer", + "@selectMapLayer": { + "description": "Title for map layer selection dialog" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "OpenStreetMap layer name" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "OpenTopoMap layer name" + }, + "esriSatellite": "ESRI Satellite", + "@esriSatellite": { + "description": "ESRI Satellite imagery layer name" + }, + "googleHybrid": "Google Hybrid", + "@googleHybrid": { + "description": "Google Hybrid layer name (satellite + labels)" + }, + "googleRoadmap": "Google Roadmap", + "@googleRoadmap": { + "description": "Google Roadmap layer name (street map)" + }, + "googleTerrain": "Google Terrain", + "@googleTerrain": { + "description": "Google Terrain layer name (topographic)" + }, + "dragToPosition": "Drag to Position", + "@dragToPosition": { + "description": "Label when dragging a pin on map" + }, + "createSarMarker": "Create SAR Marker", + "@createSarMarker": { + "description": "Label for creating SAR marker from pin" + }, + "compass": "Compass", + "@compass": { + "description": "Compass title in detailed compass dialog" + }, + "navigationAndContacts": "Navigation & Contacts", + "@navigationAndContacts": { + "description": "Subtitle for compass dialog" + }, + "sarAlert": "SAR ALERT", + "@sarAlert": { + "description": "Label for SAR alert badge on messages" + }, + "textCopiedToClipboard": "Text copied to clipboard", + "@textCopiedToClipboard": { + "description": "Success message when text is copied" + }, + "cannotReplySenderMissing": "Cannot reply: sender information missing", + "@cannotReplySenderMissing": { + "description": "Error when sender info is missing for reply" + }, + "cannotReplyContactNotFound": "Cannot reply: contact not found", + "@cannotReplyContactNotFound": { + "description": "Error when contact not found for reply" + }, + "copyText": "Copy text", + "@copyText": { + "description": "Option to copy message text to clipboard" + }, + "saveAsTemplate": "Save as Template", + "@saveAsTemplate": { + "description": "Option to save SAR message as a reusable template" + }, + "templateSaved": "Template saved successfully", + "@templateSaved": { + "description": "Success message when SAR template is saved" + }, + "templateAlreadyExists": "Template with this emoji already exists", + "@templateAlreadyExists": { + "description": "Error message when trying to save duplicate template" + }, + "deleteMessage": "Delete message", + "@deleteMessage": { + "description": "Dialog title for deleting a message" + }, + "deleteMessageConfirmation": "Are you sure you want to delete this message?", + "@deleteMessageConfirmation": { + "description": "Confirmation text for message deletion" + }, + "shareLocation": "Share location", + "@shareLocation": { + "description": "Option to share SAR marker location" + }, + "shareLocationText": "{markerInfo}\n\nCoordinates: {lat}, {lon}\n\nGoogle Maps: {url}", + "@shareLocationText": { + "description": "Formatted text for sharing SAR marker location", + "placeholders": { + "markerInfo": { + "type": "String" + }, + "lat": { + "type": "String" + }, + "lon": { + "type": "String" + }, + "url": { + "type": "String" + } + } + }, + "sarLocationShare": "SAR Location", + "@sarLocationShare": { + "description": "Subject line when sharing SAR marker location" + }, + "justNow": "Just now", + "@justNow": { + "description": "Time indicator for very recent activity" + }, + "minutesAgo": "{minutes}m ago", + "@minutesAgo": { + "description": "Time indicator for minutes ago", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "{hours}h ago", + "@hoursAgo": { + "description": "Time indicator for hours ago", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "{days}d ago", + "@daysAgo": { + "description": "Time indicator for days ago", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "{seconds}s ago", + "@secondsAgo": { + "description": "Time indicator for seconds ago", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "Sending...", + "@sending": { + "description": "Delivery status: sending" + }, + "sent": "Sent", + "@sent": { + "description": "Delivery status: sent" + }, + "delivered": "Delivered", + "@delivered": { + "description": "Delivery status: delivered" + }, + "deliveredWithTime": "Delivered ({time}ms)", + "@deliveredWithTime": { + "description": "Delivery status with round-trip time", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "Failed", + "@failed": { + "description": "Delivery status: failed" + }, + "broadcast": "Broadcast", + "@broadcast": { + "description": "Delivery status for channel messages (no echoes yet)" + }, + "deliveredToContacts": "Delivered to {delivered}/{total} contacts", + "@deliveredToContacts": { + "description": "Grouped message delivery count", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "All delivered", + "@allDelivered": { + "description": "Status when all recipients received the message" + }, + "recipientDetails": "Recipient Details", + "@recipientDetails": { + "description": "Header for expandable recipient list" + }, + "pending": "Pending", + "@pending": { + "description": "Delivery status: pending/waiting" + }, + "sarMarkerFoundPerson": "Found Person", + "@sarMarkerFoundPerson": { + "description": "SAR marker type: found person" + }, + "sarMarkerFire": "Fire Location", + "@sarMarkerFire": { + "description": "SAR marker type: fire" + }, + "sarMarkerStagingArea": "Staging Area", + "@sarMarkerStagingArea": { + "description": "SAR marker type: staging area" + }, + "sarMarkerObject": "Object Found", + "@sarMarkerObject": { + "description": "SAR marker type: object" + }, + "from": "From", + "@from": { + "description": "Sender label in notifications" + }, + "coordinates": "Coordinates", + "@coordinates": { + "description": "Coordinates label" + }, + "tapToViewOnMap": "Tap to view on map", + "@tapToViewOnMap": { + "description": "Notification action text" + }, + "radioSettings": "Radio Settings", + "@radioSettings": { + "description": "Section title for radio settings" + }, + "frequencyMHz": "Frequency (MHz)", + "@frequencyMHz": { + "description": "Label for radio frequency field" + }, + "bandwidth": "Bandwidth", + "@bandwidth": { + "description": "Label for bandwidth dropdown" + }, + "spreadingFactor": "Spreading Factor", + "@spreadingFactor": { + "description": "Label for spreading factor dropdown" + }, + "codingRate": "Coding Rate", + "@codingRate": { + "description": "Label for coding rate dropdown" + }, + "txPowerDbm": "TX Power (dBm)", + "@txPowerDbm": { + "description": "Label for TX power field" + }, + "maxPowerDbm": "Max: {power} dBm", + "@maxPowerDbm": { + "description": "Helper text showing maximum TX power", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "You", + "@you": { + "description": "Label for the current user in message bubbles" + }, + "exportFailed": "Export failed: {error}", + "@exportFailed": { + "description": "Error message when export fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "Import failed: {error}", + "@importFailed": { + "description": "Error message when import fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "Unknown", + "@unknown": { + "description": "Unknown value label" + }, + "onlineLayers": "Online Layers", + "@onlineLayers": { + "description": "Section header for online map layers" + }, + "locationTrail": "Location Trail", + "@locationTrail": { + "description": "Location trail title" + }, + "showTrailOnMap": "Show Trail on Map", + "@showTrailOnMap": { + "description": "Toggle to show/hide trail on map" + }, + "trailVisible": "Trail is visible on the map", + "@trailVisible": { + "description": "Trail visibility status - visible" + }, + "trailHiddenRecording": "Trail is hidden (still recording)", + "@trailHiddenRecording": { + "description": "Trail visibility status - hidden but recording" + }, + "duration": "Duration", + "@duration": { + "description": "Duration label" + }, + "points": "Points", + "@points": { + "description": "Trail points count label" + }, + "clearTrail": "Clear Trail", + "@clearTrail": { + "description": "Button to clear location trail" + }, + "clearTrailQuestion": "Clear Trail?", + "@clearTrailQuestion": { + "description": "Confirmation dialog title" + }, + "clearTrailConfirmation": "Are you sure you want to clear the current location trail? This action cannot be undone.", + "@clearTrailConfirmation": { + "description": "Confirmation dialog message" + }, + "noTrailRecorded": "No trail recorded yet", + "@noTrailRecorded": { + "description": "Message when no trail exists" + }, + "startTrackingToRecord": "Start location tracking to record your trail", + "@startTrackingToRecord": { + "description": "Instructions to start trail recording" + }, + "trailControls": "Trail Controls", + "@trailControls": { + "description": "Trail controls tooltip" + }, + "model": "Model", + "@model": { + "description": "Device model label" + }, + "version": "Version", + "@version": { + "description": "Version label" + }, + "maxContacts": "Max Contacts", + "@maxContacts": { + "description": "Maximum contacts capacity label" + }, + "maxChannels": "Max Channels", + "@maxChannels": { + "description": "Maximum channels capacity label" + }, + "publicInfo": "Public Info", + "@publicInfo": { + "description": "Public information section header" + }, + "useCurrentLocation": "Use current location", + "@useCurrentLocation": { + "description": "Tooltip for use current location button" + }, + "noneUnknown": "None/Unknown", + "@noneUnknown": { + "description": "Device type: none or unknown" + }, + "chatNode": "Chat Node", + "@chatNode": { + "description": "Device type: chat node" + }, + "repeater": "Repeater", + "@repeater": { + "description": "Device type: repeater" + }, + "roomChannel": "Room/Channel", + "@roomChannel": { + "description": "Device type: room or channel" + }, + "typeNumber": "Type {number}", + "@typeNumber": { + "description": "Generic device type with number", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "Failed to save: {error}", + "@failedToSave": { + "description": "Generic error message for save failures", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "Failed to get location: {error}", + "@failedToGetLocation": { + "description": "Error message when getting location fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "SAR Templates", + "@sarTemplates": { + "description": "SAR templates menu title" + }, + "addTemplate": "Add Template", + "@addTemplate": { + "description": "Button to add new SAR template" + }, + "editTemplate": "Edit Template", + "@editTemplate": { + "description": "Dialog title for editing template" + }, + "deleteTemplate": "Delete Template", + "@deleteTemplate": { + "description": "Action to delete template" + }, + "templateName": "Template Name", + "@templateName": { + "description": "Label for template name field" + }, + "templateNameHint": "e.g. Found Person", + "@templateNameHint": { + "description": "Hint text for template name" + }, + "templateEmoji": "Emoji", + "@templateEmoji": { + "description": "Label for template emoji field" + }, + "emojiRequired": "Emoji is required", + "@emojiRequired": { + "description": "Validation error when emoji field is empty" + }, + "nameRequired": "Name is required", + "@nameRequired": { + "description": "Validation error when name field is empty" + }, + "templateDescription": "Description (Optional)", + "@templateDescription": { + "description": "Label for template description field" + }, + "templateDescriptionHint": "Add additional context...", + "@templateDescriptionHint": { + "description": "Hint text for template description" + }, + "templateColor": "Color", + "@templateColor": { + "description": "Label for template color picker" + }, + "previewFormat": "Preview (SAR Message Format)", + "@previewFormat": { + "description": "Label for format preview" + }, + "importFromClipboard": "Import", + "@importFromClipboard": { + "description": "Button to import templates from clipboard" + }, + "exportToClipboard": "Export", + "@exportToClipboard": { + "description": "Button to export templates to clipboard" + }, + "deleteTemplateConfirmation": "Delete template '{name}'?", + "@deleteTemplateConfirmation": { + "description": "Confirmation message for template deletion", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "templateAdded": "Template added", + "@templateAdded": { + "description": "Success message when template is added" + }, + "templateUpdated": "Template updated", + "@templateUpdated": { + "description": "Success message when template is updated" + }, + "templateDeleted": "Template deleted", + "@templateDeleted": { + "description": "Success message when template is deleted" + }, + "templatesImported": "{count, plural, =0{No templates imported} =1{Imported 1 template} other{Imported {count} templates}}", + "@templatesImported": { + "description": "Success message after importing templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "templatesExported": "{count, plural, =1{Exported 1 template to clipboard} other{Exported {count} templates to clipboard}}", + "@templatesExported": { + "description": "Success message after exporting templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "resetToDefaults": "Reset to Defaults", + "@resetToDefaults": { + "description": "Action to reset templates to defaults" + }, + "resetToDefaultsConfirmation": "This will delete all custom templates and restore the 4 default templates. Continue?", + "@resetToDefaultsConfirmation": { + "description": "Confirmation message for reset to defaults" + }, + "reset": "Reset", + "@reset": { + "description": "Reset button label" + }, + "resetComplete": "Templates reset to defaults", + "@resetComplete": { + "description": "Success message after reset" + }, + "noTemplates": "No templates available", + "@noTemplates": { + "description": "Message when no templates exist" + }, + "tapAddToCreate": "Tap + to create your first template", + "@tapAddToCreate": { + "description": "Helper text when no templates exist" + }, + "ok": "OK", + "@ok": { + "description": "OK button label" + }, + "locationPermission": "Location Permission", + "@locationPermission": { + "description": "Location permission label" + }, + "checking": "Checking...", + "@checking": { + "description": "Loading state indicator" + }, + "locationPermissionGrantedAlways": "Granted (Always)", + "@locationPermissionGrantedAlways": { + "description": "Location permission status: granted always" + }, + "locationPermissionGrantedWhileInUse": "Granted (While In Use)", + "@locationPermissionGrantedWhileInUse": { + "description": "Location permission status: granted while in use" + }, + "locationPermissionDeniedTapToRequest": "Denied - Tap to request", + "@locationPermissionDeniedTapToRequest": { + "description": "Location permission status: denied, user can request" + }, + "locationPermissionPermanentlyDeniedOpenSettings": "Permanently Denied - Open Settings", + "@locationPermissionPermanentlyDeniedOpenSettings": { + "description": "Location permission status: permanently denied" + }, + "locationPermissionDialogContent": "Location permission is permanently denied. Please enable it in your device settings to use GPS tracking and location sharing features.", + "@locationPermissionDialogContent": { + "description": "Content for location permission dialog when permanently denied" + }, + "openSettings": "Open Settings", + "@openSettings": { + "description": "Button to open device settings" + }, + "locationPermissionGranted": "Location permission granted!", + "@locationPermissionGranted": { + "description": "Success message when location permission is granted" + }, + "locationPermissionRequiredForGps": "Location permission is required for GPS tracking and location sharing.", + "@locationPermissionRequiredForGps": { + "description": "Info message about location permission requirement" + }, + "locationPermissionAlreadyGranted": "Location permission is already granted.", + "@locationPermissionAlreadyGranted": { + "description": "Info message when permission is already granted" + }, + "sarNavyBlue": "SAR Navy Blue", + "@sarNavyBlue": { + "description": "SAR Navy Blue theme name" + }, + "sarNavyBlueDescription": "Professional/Operations Mode", + "@sarNavyBlueDescription": { + "description": "Description for SAR Navy Blue theme" + }, + "selectRecipient": "Select Recipient", + "@selectRecipient": { + "description": "Title for recipient selector sheet" + }, + "broadcastToAllNearby": "Broadcast to all nearby", + "@broadcastToAllNearby": { + "description": "Subtitle for public channel option" + }, + "searchRecipients": "Search recipients...", + "@searchRecipients": { + "description": "Placeholder text for recipient search field" + }, + "noContactsFound": "No contacts found", + "@noContactsFound": { + "description": "Message when no contacts match search" + }, + "noRoomsFound": "No rooms found", + "@noRoomsFound": { + "description": "Message when no rooms match search" + }, + "noRecipientsAvailable": "No recipients available", + "@noRecipientsAvailable": { + "description": "Message when no recipients exist (contacts, rooms, or channels)" + }, + "noChannelsFound": "No channels found", + "@noChannelsFound": { + "description": "Message when no channels match the search" + }, + "newMessage": "New message", + "@newMessage": { + "description": "Notification title for new message" + }, + "channel": "Channel", + "@channel": { + "description": "Channel label in notifications" + }, + "samplePoliceLead": "Police Lead", + "@samplePoliceLead": { + "description": "Sample team member name" + }, + "sampleDroneOperator": "Drone Operator", + "@sampleDroneOperator": { + "description": "Sample team member name" + }, + "sampleFirefighterAlpha": "Firefighter", + "@sampleFirefighterAlpha": { + "description": "Sample team member name" + }, + "sampleMedicCharlie": "Medic", + "@sampleMedicCharlie": { + "description": "Sample team member name" + }, + "sampleCommandDelta": "Command", + "@sampleCommandDelta": { + "description": "Sample team member name" + }, + "sampleFireEngine": "Fire Engine", + "@sampleFireEngine": { + "description": "Sample team member name" + }, + "sampleAirSupport": "Air Support", + "@sampleAirSupport": { + "description": "Sample team member name" + }, + "sampleBaseCoordinator": "Base Coordinator", + "@sampleBaseCoordinator": { + "description": "Sample team member name" + }, + "channelEmergency": "Emergency", + "@channelEmergency": { + "description": "Emergency channel name" + }, + "channelCoordination": "Coordination", + "@channelCoordination": { + "description": "Coordination channel name" + }, + "channelUpdates": "Updates", + "@channelUpdates": { + "description": "Updates channel name" + }, + "sampleTeamMember": "Sample Team Member", + "@sampleTeamMember": { + "description": "Sample sender name" + }, + "sampleScout": "Sample Scout", + "@sampleScout": { + "description": "Sample sender name" + }, + "sampleBase": "Sample Base", + "@sampleBase": { + "description": "Sample sender name" + }, + "sampleSearcher": "Sample Searcher", + "@sampleSearcher": { + "description": "Sample sender name" + }, + "sampleObjectBackpack": " Backpack found - blue color", + "@sampleObjectBackpack": { + "description": "Sample object note" + }, + "sampleObjectVehicle": " Vehicle abandoned - check for owner", + "@sampleObjectVehicle": { + "description": "Sample object note" + }, + "sampleObjectCamping": " Camping equipment discovered", + "@sampleObjectCamping": { + "description": "Sample object note" + }, + "sampleObjectTrailMarker": " Trail marker found off-path", + "@sampleObjectTrailMarker": { + "description": "Sample object note" + }, + "sampleMsgAllTeamsCheckIn": "All teams check in", + "@sampleMsgAllTeamsCheckIn": { + "description": "Sample channel message" + }, + "sampleMsgWeatherUpdate": "Weather update: Clear skies, temp 18°C", + "@sampleMsgWeatherUpdate": { + "description": "Sample channel message" + }, + "sampleMsgBaseCamp": "Base camp established at staging area", + "@sampleMsgBaseCamp": { + "description": "Sample channel message" + }, + "sampleMsgTeamAlpha": "Team moving to sector 2", + "@sampleMsgTeamAlpha": { + "description": "Sample channel message" + }, + "sampleMsgRadioCheck": "Radio check - all stations respond", + "@sampleMsgRadioCheck": { + "description": "Sample channel message" + }, + "sampleMsgWaterSupply": "Water supply available at checkpoint 3", + "@sampleMsgWaterSupply": { + "description": "Sample channel message" + }, + "sampleMsgTeamBravo": "Team reporting: sector 1 clear", + "@sampleMsgTeamBravo": { + "description": "Sample channel message" + }, + "sampleMsgEtaRallyPoint": "ETA to rally point: 15 minutes", + "@sampleMsgEtaRallyPoint": { + "description": "Sample channel message" + }, + "sampleMsgSupplyDrop": "Supply drop confirmed for 14:00", + "@sampleMsgSupplyDrop": { + "description": "Sample channel message" + }, + "sampleMsgDroneSurvey": "Drone survey completed - no findings", + "@sampleMsgDroneSurvey": { + "description": "Sample channel message" + }, + "sampleMsgTeamCharlie": "Team requesting backup", + "@sampleMsgTeamCharlie": { + "description": "Sample channel message" + }, + "sampleMsgRadioDiscipline": "All units: maintain radio discipline", + "@sampleMsgRadioDiscipline": { + "description": "Sample channel message" + }, + "sampleMsgUrgentMedical": "URGENT: Medical assistance needed at sector 4", + "@sampleMsgUrgentMedical": { + "description": "Sample emergency message" + }, + "sampleMsgAdultMale": " Adult male, conscious", + "@sampleMsgAdultMale": { + "description": "Sample emergency message note" + }, + "sampleMsgFireSpotted": "Fire spotted - coordinates incoming", + "@sampleMsgFireSpotted": { + "description": "Sample emergency message" + }, + "sampleMsgSpreadingRapidly": " Spreading rapidly!", + "@sampleMsgSpreadingRapidly": { + "description": "Sample emergency message note" + }, + "sampleMsgPriorityHelicopter": "PRIORITY: Need helicopter support", + "@sampleMsgPriorityHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgMedicalTeamEnRoute": "Medical team en route to your location", + "@sampleMsgMedicalTeamEnRoute": { + "description": "Sample emergency message" + }, + "sampleMsgEvacHelicopter": "Evac helicopter ETA 10 minutes", + "@sampleMsgEvacHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyResolved": "Emergency resolved - all clear", + "@sampleMsgEmergencyResolved": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyStagingArea": " Emergency staging area", + "@sampleMsgEmergencyStagingArea": { + "description": "Sample emergency message note" + }, + "sampleMsgEmergencyServices": "Emergency services notified and responding", + "@sampleMsgEmergencyServices": { + "description": "Sample emergency message" + }, + "sampleAlphaTeamLead": "Team Lead", + "@sampleAlphaTeamLead": { + "description": "Sample team name" + }, + "sampleBravoScout": "Scout", + "@sampleBravoScout": { + "description": "Sample team name" + }, + "sampleCharlieMedic": "Medic", + "@sampleCharlieMedic": { + "description": "Sample team name" + }, + "sampleDeltaNavigator": "Navigator", + "@sampleDeltaNavigator": { + "description": "Sample team name" + }, + "sampleEchoSupport": "Support", + "@sampleEchoSupport": { + "description": "Sample team name" + }, + "sampleBaseCommand": "Base Command", + "@sampleBaseCommand": { + "description": "Sample team name" + }, + "sampleFieldCoordinator": "Field Coordinator", + "@sampleFieldCoordinator": { + "description": "Sample team name" + }, + "sampleMedicalTeam": "Medical Team", + "@sampleMedicalTeam": { + "description": "Sample team name" + }, + "mapDrawing": "Map Drawing", + "@mapDrawing": { + "description": "Label for map drawing messages" + }, + "navigateToDrawing": "Navigate to Drawing", + "@navigateToDrawing": { + "description": "Option to navigate to drawing on map" + }, + "copyCoordinates": "Copy Coordinates", + "@copyCoordinates": { + "description": "Option to copy coordinates to clipboard" + }, + "hideFromMap": "Hide from Map", + "@hideFromMap": { + "description": "Option to hide drawing from map" + }, + "lineDrawing": "Line Drawing", + "@lineDrawing": { + "description": "Label for line type drawings" + }, + "rectangleDrawing": "Rectangle Drawing", + "@rectangleDrawing": { + "description": "Label for rectangle type drawings" + }, + "manualCoordinates": "Manual Coordinates", + "@manualCoordinates": { + "description": "Label for manual coordinate input toggle" + }, + "enterCoordinatesManually": "Enter coordinates manually", + "@enterCoordinatesManually": { + "description": "Description for manual coordinate input option" + }, + "latitudeLabel": "Latitude", + "@latitudeLabel": { + "description": "Label for latitude input field" + }, + "longitudeLabel": "Longitude", + "@longitudeLabel": { + "description": "Label for longitude input field" + }, + "exampleCoordinates": "Example: 46.0569, 14.5058", + "@exampleCoordinates": { + "description": "Example coordinate format hint" + }, + "shareDrawing": "Share Drawing", + "@shareDrawing": { + "description": "Title for share single drawing dialog" + }, + "shareWithAllNearbyDevices": "Share with all nearby devices", + "@shareWithAllNearbyDevices": { + "description": "Subtitle for public channel sharing option" + }, + "shareToRoom": "Share to Room", + "@shareToRoom": { + "description": "Header for room sharing section" + }, + "sendToPersistentStorage": "Send to persistent room storage", + "@sendToPersistentStorage": { + "description": "Subtitle for room sharing option" + }, + "deleteDrawingConfirm": "Are you sure you want to delete this drawing?", + "@deleteDrawingConfirm": { + "description": "Confirmation message for deleting a single drawing" + }, + "drawingDeleted": "Drawing deleted", + "@drawingDeleted": { + "description": "Success message after deleting a drawing" + }, + "yourDrawingsCount": "Your Drawings ({count})", + "@yourDrawingsCount": { + "description": "Header showing count of user's drawings", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shared": "Shared", + "@shared": { + "description": "Status label for shared drawings" + }, + "line": "Line", + "@line": { + "description": "Line drawing type label" + }, + "rectangle": "Rectangle", + "@rectangle": { + "description": "Rectangle drawing type label" + }, + "updateAvailable": "Update Available", + "@updateAvailable": { + "description": "Title for update dialog when new version is available" + }, + "currentVersion": "Current", + "@currentVersion": { + "description": "Label for current app version" + }, + "latestVersion": "Latest", + "@latestVersion": { + "description": "Label for latest available app version" + }, + "downloadUpdate": "Download", + "@downloadUpdate": { + "description": "Button to download app update" + }, + "updateLater": "Later", + "@updateLater": { + "description": "Button to dismiss update dialog" + }, + "cadastralParcels": "Cadastral Parcels", + "@cadastralParcels": { + "description": "Label for cadastral parcels WMS overlay layer" + }, + "forestRoads": "Forest Roads", + "@forestRoads": { + "description": "Label for forest roads WMS overlay layer" + }, + "wmsOverlays": "WMS Overlays", + "@wmsOverlays": { + "description": "Section header for WMS overlay layers in layer selector" + }, + "hikingTrails": "Hiking Trails", + "@hikingTrails": { + "description": "Label for hiking/mountain trails WMS overlay layer" + }, + "mainRoads": "Main Roads", + "@mainRoads": { + "description": "Label for main roads WMS overlay layer" + }, + "houseNumbers": "House Numbers", + "@houseNumbers": { + "description": "Label for house numbers WMS overlay layer" + }, + "fireHazardZones": "Fire Hazard Zones", + "@fireHazardZones": { + "description": "Label for fire hazard risk zones WMS overlay layer" + }, + "historicalFires": "Historical Fires", + "@historicalFires": { + "description": "Label for historical forest fires WMS overlay layer" + }, + "firebreaks": "Firebreaks", + "@firebreaks": { + "description": "Label for firebreaks WMS overlay layer" + }, + "krasFireZones": "Kras Fire Zones", + "@krasFireZones": { + "description": "Label for Kras fire zones WMS overlay layer" + }, + "placeNames": "Place Names", + "@placeNames": { + "description": "Label for geographic place names WMS overlay layer" + }, + "municipalityBorders": "Municipality Borders", + "@municipalityBorders": { + "description": "Label for municipality borders WMS overlay layer" + }, + "topographicMap": "Topographic Map 1:25000", + "@topographicMap": { + "description": "Label for DTK25 topographic base map layer" + }, + "recentMessages": "Recent Messages", + "@recentMessages": { + "description": "Header for recent messages overlay on map in fullscreen mode" + }, + "addChannel": "Add Channel", + "@addChannel": { + "description": "Button to add a new channel" + }, + "channelName": "Channel Name", + "@channelName": { + "description": "Label for channel name field" + }, + "channelNameHint": "e.g., Rescue Team Alpha", + "@channelNameHint": { + "description": "Hint for channel name field" + }, + "channelSecret": "Channel Secret", + "@channelSecret": { + "description": "Label for channel secret field" + }, + "channelSecretHint": "Shared password for this channel", + "@channelSecretHint": { + "description": "Hint for channel secret field" + }, + "channelSecretHelp": "This secret must be shared with all team members who need access to this channel", + "@channelSecretHelp": { + "description": "Help text explaining channel secret" + }, + "channelTypesInfo": "Hash channels (#team): Secret auto-generated from name. Same name = same channel across devices.\n\nPrivate channels: Use explicit secret. Only those with the secret can join.", + "@channelTypesInfo": { + "description": "Information banner explaining hash and private channel types" + }, + "hashChannelInfo": "Hash channel: Secret will be auto-generated from the channel name. Anyone using the same name will join the same channel.", + "@hashChannelInfo": { + "description": "Help text for hash channels (# prefix)" + }, + "channelNameRequired": "Channel name is required", + "@channelNameRequired": { + "description": "Validation error for empty channel name" + }, + "channelNameTooLong": "Channel name must be 31 characters or less", + "@channelNameTooLong": { + "description": "Validation error for channel name too long" + }, + "channelSecretRequired": "Channel secret is required", + "@channelSecretRequired": { + "description": "Validation error for empty channel secret" + }, + "channelSecretTooLong": "Channel secret must be 32 characters or less", + "@channelSecretTooLong": { + "description": "Validation error for channel secret too long" + }, + "invalidAsciiCharacters": "Only ASCII characters are allowed", + "@invalidAsciiCharacters": { + "description": "Validation error for non-ASCII characters" + }, + "channelCreatedSuccessfully": "Channel created successfully", + "@channelCreatedSuccessfully": { + "description": "Success message after creating channel" + }, + "channelCreationFailed": "Failed to create channel: {error}", + "@channelCreationFailed": { + "description": "Error message when channel creation fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deleteChannel": "Delete Channel", + "@deleteChannel": { + "description": "Delete channel button/menu item" + }, + "deleteChannelConfirmation": "Are you sure you want to delete channel \"{channelName}\"? This action cannot be undone.", + "@deleteChannelConfirmation": { + "description": "Confirmation dialog when deleting a channel", + "placeholders": { + "channelName": { + "type": "String" + } + } + }, + "channelDeletedSuccessfully": "Channel deleted successfully", + "@channelDeletedSuccessfully": { + "description": "Success message after deleting channel" + }, + "channelDeletionFailed": "Failed to delete channel: {error}", + "@channelDeletionFailed": { + "description": "Error message when channel deletion fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "createChannel": "Create Channel", + "@createChannel": { + "description": "Button text for creating a channel" + }, + "wizardBack": "Back", + "@wizardBack": { + "description": "Wizard back button text" + }, + "wizardSkip": "Skip", + "@wizardSkip": { + "description": "Wizard skip button text" + }, + "wizardNext": "Next", + "@wizardNext": { + "description": "Wizard next button text" + }, + "wizardGetStarted": "Get Started", + "@wizardGetStarted": { + "description": "Wizard final button text to complete onboarding" + }, + "wizardWelcomeTitle": "Welcome to MeshCore SAR", + "@wizardWelcomeTitle": { + "description": "Welcome wizard first page title" + }, + "viewWelcomeTutorial": "View Welcome Tutorial", + "@viewWelcomeTutorial": { + "description": "Settings option to re-show welcome wizard" + }, + "allTeamContacts": "All Team Contacts", + "@allTeamContacts": { + "description": "Destination option to send SAR marker to all team contacts" + }, + "directMessagesInfo": "Direct messages with ACKs. Sent to {count} team members.", + "@directMessagesInfo": { + "description": "Information about sending to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "sarMarkerSentToContacts": "SAR marker sent to {count} contacts", + "@sarMarkerSentToContacts": { + "description": "Success message after sending SAR marker to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noContactsAvailable": "No team contacts available", + "@noContactsAvailable": { + "description": "Message when there are no chat contacts to send to" + }, + "reply": "Reply", + "@reply": {}, + "technicalDetails": "Details", + "@technicalDetails": {}, + "messageTechnicalDetails": "Message details", + "@messageTechnicalDetails": {}, + "linkQuality": "Link quality", + "@linkQuality": {}, + "delivery": "Delivery", + "@delivery": {}, + "status": "Status", + "@status": {}, + "expectedAckTag": "Expected ACK tag", + "@expectedAckTag": {}, + "roundTrip": "Round-trip", + "@roundTrip": {}, + "retryAttempt": "Retry attempt", + "@retryAttempt": {}, + "floodFallback": "Flood fallback", + "@floodFallback": {}, + "identity": "Identity", + "@identity": {}, + "messageId": "Message ID", + "@messageId": {}, + "sender": "Sender", + "@sender": {}, + "senderKey": "Sender key", + "@senderKey": {}, + "recipient": "Recipient", + "@recipient": {}, + "recipientKey": "Recipient key", + "@recipientKey": {}, + "voice": "Voice", + "@voice": {}, + "voiceId": "Voice ID", + "@voiceId": {}, + "envelope": "Envelope", + "@envelope": {}, + "sessionProgress": "Session progress", + "@sessionProgress": {}, + "complete": "Complete", + "@complete": {}, + "rawDump": "Raw dump", + "@rawDump": {}, + "cannotRetryMissingRecipient": "Cannot retry: recipient information missing", + "@cannotRetryMissingRecipient": {}, + "voiceUnavailable": "Voice unavailable right now", + "@voiceUnavailable": {}, + "requestingVoice": "Requesting voice", + "@requestingVoice": {}, + "device": "device", + "@device": { + "description": "Generic device label" + }, + "change": "Change", + "@change": { + "description": "Generic change action" + }, + "wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.", + "@wizardOverviewDescription": { + "description": "Updated onboarding overview description" + }, + "wizardOverviewFeature1": "Send direct, room, and channel messages from the main Messages tab.", + "@wizardOverviewFeature1": { + "description": "Updated onboarding overview feature 1" + }, + "wizardOverviewFeature2": "Share 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, WiFi, or Serial, then manage the companion radio from inside the app.", + "@wizardOverviewFeature3": { + "description": "Updated onboarding overview feature 3" + }, + "wizardMessagingTitle": "Messaging and Field Reports", + "@wizardMessagingTitle": { + "description": "Updated onboarding messaging page title" + }, + "wizardMessagingDescription": "Messages are more than plain text here. The app already supports several operational payloads and transfer workflows.", + "@wizardMessagingDescription": { + "description": "Updated onboarding messaging page description" + }, + "wizardMessagingFeature1": "Send direct messages, room posts, and channel traffic from one composer.", + "@wizardMessagingFeature1": { + "description": "Updated onboarding messaging feature 1" + }, + "wizardMessagingFeature2": "Create SAR updates and reusable SAR templates for common field reports.", + "@wizardMessagingFeature2": { + "description": "Updated onboarding messaging feature 2" + }, + "wizardMessagingFeature3": "Transfer voice sessions and images, with progress and airtime estimates in the UI.", + "@wizardMessagingFeature3": { + "description": "Updated onboarding messaging feature 3" + }, + "wizardConnectDeviceTitle": "Connect device", + "@wizardConnectDeviceTitle": { + "description": "Onboarding connect device page title" + }, + "wizardConnectDeviceDescription": "Connect your MeshCore radio, choose a name, and apply a radio preset before continuing.", + "@wizardConnectDeviceDescription": { + "description": "Onboarding connect device page description" + }, + "wizardSetupBadge": "Setup", + "@wizardSetupBadge": { + "description": "Onboarding setup badge label" + }, + "wizardOverviewBadge": "Overview", + "@wizardOverviewBadge": { + "description": "Onboarding overview badge label" + }, + "wizardConnectedToDevice": "Connected to {deviceName}", + "@wizardConnectedToDevice": { + "description": "Onboarding connection status when connected", + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "wizardNoDeviceConnected": "No device connected yet", + "@wizardNoDeviceConnected": { + "description": "Onboarding connection status when disconnected" + }, + "wizardSkipForNow": "Skip for now", + "@wizardSkipForNow": { + "description": "Onboarding skip current setup step action" + }, + "wizardDeviceNameLabel": "Device name", + "@wizardDeviceNameLabel": { + "description": "Onboarding device name field label" + }, + "wizardDeviceNameHelp": "This name is advertised to other MeshCore users.", + "@wizardDeviceNameHelp": { + "description": "Onboarding device name field helper text" + }, + "wizardConfigRegionLabel": "Config region", + "@wizardConfigRegionLabel": { + "description": "Onboarding config region field label" + }, + "wizardConfigRegionHelp": "Uses the full official MeshCore preset list. Default is EU/UK (Narrow).", + "@wizardConfigRegionHelp": { + "description": "Onboarding config region helper text" + }, + "wizardPresetNote1": "Make sure the selected preset matches your local radio regulations.", + "@wizardPresetNote1": { + "description": "Onboarding preset note 1" + }, + "wizardPresetNote2": "The list matches the official MeshCore config tool preset feed.", + "@wizardPresetNote2": { + "description": "Onboarding preset note 2" + }, + "wizardPresetNote3": "EU/UK (Narrow) stays selected by default for onboarding.", + "@wizardPresetNote3": { + "description": "Onboarding preset note 3" + }, + "wizardSaving": "Saving...", + "@wizardSaving": { + "description": "Onboarding saving button label" + }, + "wizardSaveAndContinue": "Save and continue", + "@wizardSaveAndContinue": { + "description": "Onboarding save and continue button label" + }, + "wizardEnterDeviceName": "Enter a device name before continuing.", + "@wizardEnterDeviceName": { + "description": "Onboarding validation when device name is missing" + }, + "wizardDeviceSetupSaved": "Saved {deviceName} with {presetName}.", + "@wizardDeviceSetupSaved": { + "description": "Onboarding success message after saving device setup", + "placeholders": { + "deviceName": { + "type": "String" + }, + "presetName": { + "type": "String" + } + } + }, + "wizardNetworkTitle": "Contacts, Rooms, and Repeaters", + "@wizardNetworkTitle": { + "description": "Updated onboarding network page title" + }, + "wizardNetworkDescription": "The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.", + "@wizardNetworkDescription": { + "description": "Updated onboarding network page description" + }, + "wizardNetworkFeature1": "Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.", + "@wizardNetworkFeature1": { + "description": "Updated onboarding network feature 1" + }, + "wizardNetworkFeature2": "Use smart ping, room login, learned paths, and route reset tools when connectivity gets messy.", + "@wizardNetworkFeature2": { + "description": "Updated onboarding network feature 2" + }, + "wizardNetworkFeature3": "Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.", + "@wizardNetworkFeature3": { + "description": "Updated onboarding network feature 3" + }, + "wizardMapOpsTitle": "Map, Trails, and Shared Geometry", + "@wizardMapOpsTitle": { + "description": "Updated onboarding map page title" + }, + "wizardMapOpsDescription": "The app map is tied directly into messaging, tracking, and SAR overlays instead of being a separate viewer.", + "@wizardMapOpsDescription": { + "description": "Updated onboarding map page description" + }, + "wizardMapOpsFeature1": "Track your own position, teammate locations, and share live location to a private channel from the channel menu.", + "@wizardMapOpsFeature1": { + "description": "Updated onboarding map feature 1" + }, + "wizardMapOpsFeature2": "Open drawings from messages, preview them inline, and remove them from the map when needed.", + "@wizardMapOpsFeature2": { + "description": "Updated onboarding map feature 2" + }, + "wizardMapOpsFeature3": "Use repeater map views and shared overlays to understand network reach in the field.", + "@wizardMapOpsFeature3": { + "description": "Updated onboarding map feature 3" + }, + "wizardToolsTitle": "Tools Beyond Messaging", + "@wizardToolsTitle": { + "description": "Updated onboarding tools page title" + }, + "wizardToolsDescription": "There is more here than the four main tabs. The app also includes configuration, diagnostics, and optional sensor workflows.", + "@wizardToolsDescription": { + "description": "Updated onboarding tools page description" + }, + "wizardToolsFeature1": "Open device config to change radio settings, telemetry, TX power, and companion details.", + "@wizardToolsFeature1": { + "description": "Updated onboarding tools feature 1" + }, + "wizardToolsFeature2": "Enable the Sensors tab when you want watched sensor dashboards and quick refresh actions.", + "@wizardToolsFeature2": { + "description": "Updated onboarding tools feature 2" + }, + "wizardToolsFeature3": "Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.", + "@wizardToolsFeature3": { + "description": "Updated onboarding tools feature 3" + }, + "postConnectDiscoveryTitle": "Discover repeaters now?", + "@postConnectDiscoveryTitle": { + "description": "Title for the prompt shown after a successful device connection" + }, + "postConnectDiscoveryDescription": "Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.", + "@postConnectDiscoveryDescription": { + "description": "Body text for the prompt shown after a successful device connection" + }, + "contactInSensors": "In Sensors", + "@contactInSensors": { + "description": "Contact actions label when already in sensors" + }, + "contactAddToSensors": "Add to Sensors", + "@contactAddToSensors": { + "description": "Contact actions label to add to sensors" + }, + "contactSetPath": "Set path", + "@contactSetPath": { + "description": "Contact actions label to set route path" + }, + "contactAddedToSensors": "{contactName} added to Sensors", + "@contactAddedToSensors": { + "description": "Snackbar after adding contact to sensors", + "placeholders": { + "contactName": { + "type": "String" + } + } + }, + "contactFailedToClearRoute": "Failed to clear route: {error}", + "@contactFailedToClearRoute": { + "description": "Error when clearing a contact route fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "contactRouteCleared": "Route cleared", + "@contactRouteCleared": { + "description": "Snackbar after route is cleared" + }, + "contactRouteSet": "Route set: {route}", + "@contactRouteSet": { + "description": "Snackbar after route is set", + "placeholders": { + "route": { + "type": "String" + } + } + }, + "contactFailedToSetRoute": "Failed to set route: {error}", + "@contactFailedToSetRoute": { + "description": "Error when setting a contact route fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "rssi": "RSSI", + "@rssi": { + "description": "Received signal strength indicator" + }, + "snr": "SNR", + "@snr": { + "description": "Signal-to-noise ratio" + }, + "ackTimeout": "ACK timeout", + "opcode": "Opcode", + "payload": "Payload", + "hops": "Hops", + "hashSize": "Hash size", + "pathBytes": "Path bytes", + "selectedPath": "Selected path", + "estimatedTx": "Estimated tx", + "senderToReceipt": "Sender to receipt", + "receivedCopies": "Received copies", + "retryCause": "Retry cause", + "retryMode": "Retry mode", + "retryResult": "Retry result", + "lastRetry": "Last retry", + "rxPackets": "RX packets", + "mesh": "Mesh", + "rate": "Rate", + "window": "Window", + "posttxDelay": "Post-tx delay", + "bandpass": "Band-pass", + "bandpassFilterVoice": "Band-pass filter voice", + "active": "Active", + "addContact": "Add Contact", + "all": "All", + "clearAllLabel": "Clear all", + "clearFilters": "Clear filters", + "clearRoute": "Clear Route", + "clearMessages": "Clear Messages", + "clearScale": "Clear scale", + "clearDiscoveries": "Clear discoveries", + "clearOnlineTraceDatabase": "Clear online trace database", + "clearAllChannels": "Clear all channels", + "clearAllContacts": "Clear all contacts", + "clearChannels": "Clear channels", + "clearContacts": "Clear contacts", + "clearPathOnMaxRetry": "Clear path on max retry", + "create": "Create", + "custom": "Custom", + "defaultValue": "Default", + "duplicate": "Duplicate", + "editName": "Edit name", + "open": "Open", + "paste": "Paste", + "preview": "Preview", + "remove": "Remove", + "rename": "Rename", + "resolveAll": "Resolve all", + "send": "Send", + "sendAnyway": "Send anyway", + "share": "Share", + "trace": "Trace", + "discovery": "Discovery", + "discoverRepeaters": "Discover repeaters", + "discoverSensors": "Discover sensors", + "repeaterDiscoverySent": "Repeater discovery sent", + "sensorDiscoverySent": "Sensor discovery sent", + "clearedPendingDiscoveries": "Cleared pending discoveries.", + "autoDiscovery": "Auto discovery", + "enableAutomaticAdding": "Enable automatic adding", + "autoaddRepeaters": "Auto-add repeaters", + "autoaddRoomServers": "Auto-add room servers", + "autoaddSensors": "Auto-add sensors", + "autoaddUsers": "Auto-add users", + "overwriteOldestWhenFull": "Overwrite oldest when full", + "storage": "Storage", + "dangerZone": "Danger zone", + "profiles": "Profiles", + "favourites": "Favourites", + "sensors": "Sensors", + "others": "Others", + "gpsModule": "GPS Module", + "liveTraffic": "Live Traffic", + "repeatersMap": "Repeaters Map", + "blePacketLogs": "BLE Packet Logs", + "onlineTraceDatabase": "Online trace database", + "routePathByteSize": "Route path byte size", + "messageNotifications": "Message notifications", + "sarAlerts": "SAR alerts", + "discoveryNotifications": "Discovery notifications", + "updateNotifications": "Update notifications", + "muteWhileAppIsOpen": "Mute while app is open", + "disableContacts": "Disable Contacts", + "enableSensorsTab": "Enable Sensors tab", + "enableProfiles": "Enable Profiles", + "nearestRepeaterFallback": "Nearest repeater fallback", + "deleteAllStoredMessageHistory": "Delete all stored message history", + "messageFontSize": "Message font size", + "rotateMapWithHeading": "Rotate map with heading", + "showMapDebugInfo": "Show map debug info", + "openMapInFullscreen": "Open map in fullscreen", + "showSarMarkersLabel": "Show SAR markers", + "hideRepeatersOnMap": "Hide repeaters on map", + "setMapScale": "Set map scale", + "customMapScaleSaved": "Custom map scale saved", + "voiceBitrate": "Voice bitrate", + "voiceCompressor": "Voice compressor", + "voiceLimiter": "Voice limiter", + "micAutoGain": "Mic auto gain", + "echoCancellation": "Echo cancellation", + "noiseSuppression": "Noise suppression", + "trimSilenceInVoiceMessages": "Trim silence in voice messages", + "compressor": "Compressor", + "limiter": "Limiter", + "autoGain": "Auto gain", + "echoCancel": "Echo cancel", + "noiseSuppress": "Noise suppress", + "silenceTrim": "Silence trim", + "maxImageSize": "Max image size", + "imageCompression": "Image compression", + "grayscale": "Grayscale", + "ultraMode": "Ultra mode", + "fastPrivateGpsUpdates": "Fast private GPS updates", + "movementThreshold": "Movement threshold", + "fastGpsMovementThreshold": "Fast GPS movement threshold", + "fastGpsActiveuseInterval": "Fast GPS active-use interval", + "activeuseUpdateInterval": "Active-use update interval", + "repeatNearbyTraffic": "Repeat nearby traffic", + "relayThroughRepeatersAcrossTheMesh": "Relay through repeaters across the mesh", + "nearbyOnlyWithoutRepeaterFlooding": "Nearby only, without repeater flooding", + "multihop": "Multi-hop", + "createProfile": "Create Profile", + "renameProfile": "Rename Profile", + "newProfile": "New Profile", + "manageProfiles": "Manage profiles", + "enableProfilesToStartManagingThem": "Enable profiles to start managing them.", + "openMessage": "Open message", + "jumpToTheRelatedSarMessage": "Jump to the related SAR message", + "removeSarMarker": "Remove SAR marker", + "pleaseSelectADestinationToSendSarMarker": "Please select a destination to send SAR marker", + "sarMarkerBroadcastToPublicChannel": "SAR marker broadcast to public channel", + "sarMarkerSentToRoom": "SAR marker sent to room", + "loadFromGallery": "Load from gallery", + "replaceImage": "Replace image", + "selectFromGallery": "Select from gallery", + "team": "Team", + "found": "Found", + "staging": "Staging", + "object": "Object", + "quiet": "Quiet", + "busy": "Busy", + "searchMessages": "Search messages", + "sendImageFromGallery": "Send image from gallery", + "takePhoto": "Take photo", + "allMessages": "All messages", + "sendToPublicChannel": "Send to Public Channel?", + "selectMarkerTypeAndDestination": "Select marker type and destination", + "noDestinationsAvailableLabel": "No destinations available", + "image": "Image", + "format": "Format", + "dimensions": "Dimensions", + "segments": "Segments", + "transfers": "Transfers", + "downloadedBy": "Downloaded by", + "saveDiscoverySettings": "Save discovery settings", + "savePublicInfo": "Save public info", + "saveRadioSettings": "Save radio settings", + "savePath": "Save Path", + "wipeDeviceData": "Wipe device data", + "wipeDevice": "Wipe device", + "destructiveDeviceActions": "Destructive device actions.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Choose a preset or fine-tune custom radio settings.", + "used": "Used", + "total": "Total", + "renameValue": "Rename value", + "customizeFields": "Customize fields", + "livePreview": "Live preview", + "refreshSchedule": "Refresh schedule", + "noResponse": "No response", + "refreshing": "Refreshing", + "unavailable": "Unavailable", + "pickARelayOrNodeToWatchInSensors": "Pick a relay or node to watch in Sensors.", + "publicKeyLabel": "Public key", + "alreadyInContacts": "Already in contacts", + "connectToADeviceBeforeAddingContacts": "Connect to a device before adding contacts", + "fromContacts": "From contacts", + "onlineOnly": "Online only", + "inBoth": "In both", + "source": "Source", + "allMessagesCleared": "All messages cleared", + "onlineTraceDatabaseCleared": "Online trace database cleared", + "packetLogsCleared": "Packet logs cleared", + "hexDataCopiedToClipboard": "Hex data copied to clipboard", + "developerModeEnabled": "Developer mode enabled", + "developerModeDisabled": "Developer mode disabled", + "clipboardIsEmpty": "Clipboard is empty", + "contactImported": "Contact imported", + "contactLinkCopiedToClipboard": "Contact link copied to clipboard", + "failedToExportContact": "Failed to export contact", + "noLogsToExport": "No logs to export", + "exportAsCsv": "Export as CSV", + "exportAsText": "Export as Text", + "receivedRfc3339": "Received (RFC3339)", + "buildTime": "Build Time", + "downloadUrlNotAvailable": "Download URL not available", + "cannotOpenDownloadUrl": "Cannot open download URL", + "updateCheckIsOnlyAvailableOnAndroid": "Update check is only available on Android", + "youAreRunningTheLatestVersion": "You are running the latest version", + "updateAvailableButDownloadUrlNotFound": "Update available but download URL not found", + "startTictactoe": "Start Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe unavailable", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: opponent unknown", + "tictactoeWaitingForStart": "Tic-Tac-Toe: waiting for start", + "acceptsShareLinks": "Accepts share links", + "supportsRawHex": "Supports raw hex", + "clipboardfriendly": "Clipboard-friendly", + "captured": "Captured", + "size": "Size", + "noCustomChannelsToClear": "No custom channels to clear.", + "noDeviceContactsToClear": "No device contacts to clear.", + "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", + "simpleMode": "Simple mode", + "simpleModeDescription": "Only chat and map with favourite contacts", + "simpleModeRequiresLockedDestination": "Enable \"Lock destination\" with a channel first to use Simple mode", + "noFavouriteContacts": "No favourite contacts", + "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" + } + } + }, + "selectDestinationFirst": "Select a channel, contact, or room first", + "@selectDestinationFirst": { + "description": "" + }, + "recipientNoLongerAvailable": "Recipient is no longer available", + "@recipientNoLongerAvailable": { + "description": "" + }, + "failedToSendMessage": "Failed to send: {error}", + "@failedToSendMessage": { + "description": "", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tictactoeDirectOnly": "Tic-Tac-Toe works only in direct messages. Choose a contact first.", + "@tictactoeDirectOnly": { + "description": "" + }, + "deviceKeyUnavailable": "Device key is unavailable", + "@deviceKeyUnavailable": { + "description": "" + }, + "imageCompressionFailed": "Image compression failed", + "@imageCompressionFailed": { + "description": "" + }, + "imageFragmentationFailed": "Image fragmentation failed", + "@imageFragmentationFailed": { + "description": "" + }, + "failedToAnnounceImage": "Failed to announce image", + "@failedToAnnounceImage": { + "description": "" + }, + "noRecipientSelected": "No recipient selected", + "@noRecipientSelected": { + "description": "" + }, + "imageSendFailed": "Image send failed", + "@imageSendFailed": { + "description": "" + }, + "microphonePermissionRequiredForVoice": "Microphone permission required for voice", + "@microphonePermissionRequiredForVoice": { + "description": "" + }, + "recordingDiscarded": "Recording discarded", + "@recordingDiscarded": { + "description": "" + }, + "publicChannelMediaWarning": "You are about to send {mediaType} to the Public Channel. This is not advised because everyone on the mesh may receive it. Choose a private or tagged channel unless this is what you want.", + "@publicChannelMediaWarning": { + "description": "", + "placeholders": { + "mediaType": { + "type": "String" + } + } + }, + "selectPrivateChannelToShareLocation": "Select a private channel to share your location.", + "@selectPrivateChannelToShareLocation": { + "description": "" + }, + "stopSharingMyLocation": "Stop sharing my location", + "@stopSharingMyLocation": { + "description": "" + }, + "shareMyLocation": "Share my location", + "@shareMyLocation": { + "description": "" + }, + "stopRecording": "Stop recording", + "@stopRecording": { + "description": "" + }, + "recordVoice": "Record voice", + "@recordVoice": { + "description": "" + }, + "moreActions": "More actions", + "@moreActions": { + "description": "" + }, + "noMessagesInFilter": "No messages in this filter", + "@noMessagesInFilter": { + "description": "" + }, + "noMatchesInFilter": "No matches in this filter", + "@noMatchesInFilter": { + "description": "" + }, + "syncFailed": "Sync failed: {error}", + "@syncFailed": { + "description": "", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "invalidLink": "Invalid link", + "@invalidLink": { + "description": "" + }, + "cannotOpenLink": "Cannot open link", + "@cannotOpenLink": { + "description": "" + }, + "failedToOpenLink": "Failed to open link", + "@failedToOpenLink": { + "description": "" + }, + "failedToResendMessage": "Failed to resend message", + "@failedToResendMessage": { + "description": "" + }, + "retryFailed": "Retry failed: {error}", + "@retryFailed": { + "description": "", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deleteDrawingAndMessage": "Delete drawing & message", + "@deleteDrawingAndMessage": { + "description": "" + }, + "lastEchoRelay": "Last echo relay", + "@lastEchoRelay": { + "description": "" + }, + "lastEchoPath": "Last echo path", + "@lastEchoPath": { + "description": "" + }, + "lastEchoBytesReport": "Last echo bytes report", + "@lastEchoBytesReport": { + "description": "" + }, + "noGpsCoordinatesAvailable": "No GPS coordinates available", + "@noGpsCoordinatesAvailable": { + "description": "" + }, + "notASarMarker": "Not a SAR marker", + "@notASarMarker": { + "description": "" + }, + "failedToSaveTemplate": "Failed to save template: {error}", + "@failedToSaveTemplate": { + "description": "", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "drawingNotFound": "Drawing not found", + "@drawingNotFound": { + "description": "" + }, + "unknownDrawingType": "Unknown drawing type", + "@unknownDrawingType": { + "description": "" + }, + "customMapMarker": "Custom map marker", + "@customMapMarker": { + "description": "" + }, + "customMapPointLabel": "Point: {point}", + "@customMapPointLabel": { + "description": "", + "placeholders": { + "point": { + "type": "String" + } + } + }, + "mapIdLabel": "Map ID: {id}", + "@mapIdLabel": { + "description": "", + "placeholders": { + "id": { + "type": "String" + } + } + }, + "cannotFetchVoice": "Cannot fetch voice", + "@cannotFetchVoice": { + "description": "" + }, + "senderContactUnknown": "Sender contact is unknown. Sync contacts first.", + "@senderContactUnknown": { + "description": "" + }, + "senderRouteUnknown": "Sender route is unknown. Sync contacts/path first.", + "@senderRouteUnknown": { + "description": "" + }, + "messageTooFar": "Message is too far ({hops} hops, max {maxHops}).", + "@messageTooFar": { + "description": "", + "placeholders": { + "hops": { + "type": "String" + }, + "maxHops": { + "type": "String" + } + } + }, + "senderRouteNoPathResponse": "Sender route did not respond to a path check. Sync contacts/path and try again.", + "@senderRouteNoPathResponse": { + "description": "" + }, + "senderRouteNoRawResponse": "Sender route did not respond on the raw transport path.", + "@senderRouteNoRawResponse": { + "description": "" + }, + "voiceFetchOverHops": "Voice fetch over {hops} hops may take a while.", + "@voiceFetchOverHops": { + "description": "", + "placeholders": { + "hops": { + "type": "String" + } + } + }, + "voiceReceiveCanceled": "Voice receive canceled", + "@voiceReceiveCanceled": { + "description": "" + }, + "fetchingMissingVoiceFragments": "Fetching missing voice fragments", + "@fetchingMissingVoiceFragments": { + "description": "" + }, + "receivingVoice": "Receiving voice", + "@receivingVoice": { + "description": "" + }, + "cannotFetchImage": "Cannot fetch image", + "@cannotFetchImage": { + "description": "" + }, + "imageFetchOverHops": "Image fetch over {hops} hops may take a while.", + "@imageFetchOverHops": { + "description": "", + "placeholders": { + "hops": { + "type": "String" + } + } + }, + "imageAlreadyBeingReceived": "Image is already being received", + "@imageAlreadyBeingReceived": { + "description": "" + }, + "loadImage": "Load image", + "@loadImage": { + "description": "" + }, + "imageFetchFailedToSendRequest": "Image fetch failed to send request", + "@imageFetchFailedToSendRequest": { + "description": "" + }, + "imageUnavailable": "Image unavailable right now", + "@imageUnavailable": { + "description": "" + }, + "imageFetchTimedOut": "Image fetch timed out", + "@imageFetchTimedOut": { + "description": "" + }, + "imageReceiveCanceled": "Image receive canceled", + "@imageReceiveCanceled": { + "description": "" + }, + "closeImagePreview": "Close image preview", + "@closeImagePreview": { + "description": "" + }, + "recordingReleaseToSend": "Recording... release to send voice", + "@recordingReleaseToSend": { + "description": "" + }, + "sendingVoice": "Sending voice...", + "@sendingVoice": { + "description": "" + }, + "sendLongPressToRecordVoice": "Send (long press to record voice)", + "@sendLongPressToRecordVoice": { + "description": "" + }, + "discardRecording": "Discard recording", + "@discardRecording": { + "description": "" + }, + "hopCount": "{count, plural, =1{1 hop} other{{count} hops}}", + "@hopCount": { + "description": "", + "placeholders": { + "count": { + "type": "num" + } + } + }, + "channelsSectionDescription": "Broadcast lanes for nearby mesh traffic", + "@channelsSectionDescription": { + "description": "" + }, + "roomsSectionDescription": "Shared spaces for ongoing team coordination", + "@roomsSectionDescription": { + "description": "" + }, + "contactsSectionDescription": "Direct people and devices you can reach", + "@contactsSectionDescription": { + "description": "" + }, + "noRecentChatters": "No recent chatters", + "@noRecentChatters": { + "description": "" + }, + "public": "Public", + "@public": { + "description": "" + }, + "failedToSendTicTacToeMove": "Failed to send Tic-Tac-Toe move", + "@failedToSendTicTacToeMove": { + "description": "" + }, + "youWon": "You won", + "@youWon": { + "description": "" + }, + "opponentWon": "Opponent won", + "@opponentWon": { + "description": "" + }, + "gameDraw": "Draw", + "@gameDraw": { + "description": "" + }, + "yourTurn": "Your turn", + "@yourTurn": { + "description": "" + }, + "opponentTurn": "Opponent turn", + "@opponentTurn": { + "description": "" + }, + "pleaseEnterValidCoordinates": "Please enter valid coordinates", + "@pleaseEnterValidCoordinates": { + "description": "" + }, + "locationNotAvailable": "Location not available", + "@locationNotAvailable": { + "description": "" + }, + "customCaveMapPoint": "Custom cave map point", + "@customCaveMapPoint": { + "description": "" + }, + "mapPoint": "Map point", + "@mapPoint": { + "description": "" + }, + "fetchingMissingImageFragments": "Fetching missing fragments…", + "@fetchingMissingImageFragments": { + "description": "" + }, + "loadingImage": "Loading…", + "@loadingImage": { + "description": "" + }, + "receivingImage": "Receiving…", + "@receivingImage": { + "description": "" + }, + "tapToLoad": "Tap to load", + "@tapToLoad": { + "description": "" + }, + "connectDevice": "Connect Device", + "@connectDevice": { + "description": "Connection dialog title" + }, + "chooseTransportSubtitle": "Choose Bluetooth, WiFi, or Serial transport", + "@chooseTransportSubtitle": { + "description": "Connection dialog subtitle describing available transports" + }, + "ble": "BLE", + "@ble": { + "description": "Bluetooth Low Energy tab label in connection dialog" + }, + "network": "Network", + "@network": { + "description": "Network tab label in connection dialog" + }, + "serial": "Serial", + "@serial": { + "description": "Serial tab label in connection dialog" + }, + "scan": "Scan", + "@scan": { + "description": "Button label to start scanning for devices" + }, + "pressScanToSearchForDevices": "Press scan to search for nearby devices", + "@pressScanToSearchForDevices": { + "description": "Empty state shown before the first BLE scan" + }, + "failedToConnectToDevice": "Failed to connect to {name}", + "@failedToConnectToDevice": { + "description": "Error when connecting to a BLE device fails", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unknownDevice": "Unknown Device", + "@unknownDevice": { + "description": "Fallback name for a BLE device without a name" + }, + "showingCachedResultsTapRefresh": "Showing cached results. Tap refresh to rescan.", + "@showingCachedResultsTapRefresh": { + "description": "Banner shown when cached network scan results are displayed" + }, + "scanningLocalNetworkOnPort": "Scanning local network for MeshCore WiFi devices on port {port}", + "@scanningLocalNetworkOnPort": { + "description": "Banner shown while scanning the local network", + "placeholders": { + "port": { + "type": "int" + } + } + }, + "cancelScanAndAddServer": "Cancel scan and add server", + "@cancelScanAndAddServer": { + "description": "Tooltip for the add-server button while a network scan is running" + }, + "addServer": "Add server", + "@addServer": { + "description": "Tooltip for the add-server button" + }, + "scanningProgressIps": "Scanning... {scanned}/{total} IPs", + "@scanningProgressIps": { + "description": "Network scan progress label", + "placeholders": { + "scanned": { + "type": "int" + }, + "total": { + "type": "String" + } + } + }, + "cancelAndEnterManually": "Cancel and enter manually", + "@cancelAndEnterManually": { + "description": "Button to cancel the network scan and enter a server address manually" + }, + "noRecentOrDiscoveredServers": "No recent or discovered servers yet", + "@noRecentOrDiscoveredServers": { + "description": "Empty state for the network servers tab" + }, + "recentlyUsed": "Recently used", + "@recentlyUsed": { + "description": "Section header for recently used servers" + }, + "connecting": "Connecting...", + "@connecting": { + "description": "Status label while a connection is being established" + }, + "discoveredOnThisNetwork": "Discovered on this network", + "@discoveredOnThisNetwork": { + "description": "Section header for servers discovered on the local network" + }, + "serverNoLongerAvailable": "Server at {host}:{port} is no longer available. Please scan again to find active servers.", + "@serverNoLongerAvailable": { + "description": "Error when a previously discovered server does not respond anymore", + "placeholders": { + "host": { + "type": "String" + }, + "port": { + "type": "int" + } + } + }, + "failedToConnectToHost": "Failed to connect to {host}:{port}", + "@failedToConnectToHost": { + "description": "Error when a TCP connection attempt fails", + "placeholders": { + "host": { + "type": "String" + }, + "port": { + "type": "int" + } + } + }, + "enterValidIpAddress": "Enter a valid IP address", + "@enterValidIpAddress": { + "description": "Validation error for the IP address field" + }, + "enterValidTcpPort": "Enter a valid TCP port", + "@enterValidTcpPort": { + "description": "Validation error for the TCP port field" + }, + "ipAddress": "IP address", + "@ipAddress": { + "description": "Label for the IP address text field" + }, + "tcpPort": "TCP port", + "@tcpPort": { + "description": "Label for the TCP port text field" + }, + "customServerPort": "Custom server port", + "@customServerPort": { + "description": "Helper text for the TCP port text field" + }, + "chat": "Chat", + "@chat": { + "description": "Chat tab label on the home screen" + }, + "restoringPreviousLink": "Restoring previous link", + "@restoringPreviousLink": { + "description": "Status text while automatically reconnecting to the last device" + }, + "noDeviceConnected": "No device connected", + "@noDeviceConnected": { + "description": "Status text when no device is connected" + }, + "sendAdvert": "Send my contact", + "@sendAdvert": { + "description": "Composer action that broadcasts this device's contact info (advert) to the mesh" + }, + "advertMode": "Send my contact", + "@advertMode": { + "description": "Title of the sheet choosing how far the contact broadcast travels" + }, + "advertModeSubtitle": "Choose how far your contact info should travel.", + "@advertModeSubtitle": { + "description": "Subtitle of the contact broadcast mode sheet" + }, + "floodAdvertSent": "Contact sent across the mesh", + "@floodAdvertSent": { + "description": "Toast after broadcasting contact info via flood (through repeaters)" + }, + "directAdvertSent": "Contact sent to nearby devices", + "@directAdvertSent": { + "description": "Toast after broadcasting contact info direct (zero-hop, nearby only)" + } +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb new file mode 100644 index 0000000..cc3a949 --- /dev/null +++ b/lib/l10n/app_es.arb @@ -0,0 +1,1999 @@ +{ + "@@locale": "es", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "El título de la aplicación" + }, + "messages": "Mensajes", + "@messages": { + "description": "Etiqueta de la pestaña de mensajes" + }, + "contacts": "Contactos", + "@contacts": { + "description": "Etiqueta de la pestaña de contactos" + }, + "map": "Mapa", + "@map": { + "description": "Etiqueta de la pestaña del mapa" + }, + "settings": "Configuración", + "@settings": { + "description": "Título de la pantalla de configuración" + }, + "connect": "Conectar", + "@connect": { + "description": "Etiqueta del botón de conectar" + }, + "disconnect": "Desconectar", + "@disconnect": { + "description": "Etiqueta del botón de desconectar" + }, + "noDevicesFound": "No se encontraron dispositivos", + "@noDevicesFound": { + "description": "Texto mostrado cuando no se encuentran dispositivos BLE" + }, + "scanAgain": "Buscar de nuevo", + "@scanAgain": { + "description": "Botón para reiniciar el escaneo BLE" + }, + "deviceNotConnected": "Dispositivo no conectado", + "@deviceNotConnected": { + "description": "Mensaje de error cuando el dispositivo no está conectado" + }, + "locationPermissionDenied": "Permiso de ubicación denegado", + "@locationPermissionDenied": { + "description": "Error cuando se deniega el permiso de ubicación" + }, + "locationPermissionPermanentlyDenied": "Permiso de ubicación denegado permanentemente. Por favor, actívalo en Configuración.", + "@locationPermissionPermanentlyDenied": { + "description": "Error cuando el permiso de ubicación se deniega permanentemente" + }, + "locationPermissionRequired": "El permiso de ubicación es necesario para el seguimiento GPS y la coordinación del equipo. Puedes activarlo más tarde en Configuración.", + "@locationPermissionRequired": { + "description": "Mensaje cuando se necesita el permiso de ubicación" + }, + "locationServicesDisabled": "Los servicios de ubicación están desactivados. Por favor, actívalos en Configuración.", + "@locationServicesDisabled": { + "description": "Error cuando los servicios de ubicación están desactivados" + }, + "failedToGetGpsLocation": "Error al obtener la ubicación GPS", + "@failedToGetGpsLocation": { + "description": "Error cuando no se puede obtener la ubicación GPS" + }, + "failedToAdvertise": "Error al anunciar: {error}", + "@failedToAdvertise": { + "description": "Mensaje de error para anuncio fallido", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "Cancelar reconexión", + "@cancelReconnection": { + "description": "Tooltip para el botón de cancelar reconexión" + }, + "general": "General", + "@general": { + "description": "Encabezado de la sección de configuración general" + }, + "theme": "Tema", + "@theme": { + "description": "Etiqueta de la configuración del tema" + }, + "chooseTheme": "Elegir tema", + "@chooseTheme": { + "description": "Título del diálogo de selección de tema" + }, + "light": "Claro", + "@light": { + "description": "Opción de tema claro" + }, + "dark": "Oscuro", + "@dark": { + "description": "Opción de tema oscuro" + }, + "blueLightTheme": "Tema azul claro", + "@blueLightTheme": { + "description": "Descripción del tema azul claro" + }, + "blueDarkTheme": "Tema azul oscuro", + "@blueDarkTheme": { + "description": "Descripción del tema azul oscuro" + }, + "sarRed": "SAR Rojo", + "@sarRed": { + "description": "Opción de tema SAR Rojo" + }, + "alertEmergencyMode": "Modo de alerta/emergencia", + "@alertEmergencyMode": { + "description": "Descripción del tema SAR Rojo" + }, + "sarGreen": "SAR Verde", + "@sarGreen": { + "description": "Opción de tema SAR Verde" + }, + "safeAllClearMode": "Modo seguro/todo despejado", + "@safeAllClearMode": { + "description": "Descripción del tema SAR Verde" + }, + "autoSystem": "Auto (Sistema)", + "@autoSystem": { + "description": "Opción de tema automático/sistema" + }, + "followSystemTheme": "Seguir el tema del sistema", + "@followSystemTheme": { + "description": "Descripción del tema del sistema" + }, + "showRxTxIndicators": "Mostrar indicadores RX/TX", + "@showRxTxIndicators": { + "description": "Configuración para mostrar indicadores RX/TX" + }, + "disableMap": "Desactivar mapa", + "@disableMap": { + "description": "Configuración para desactivar la pestaña del mapa" + }, + "language": "Idioma", + "@language": { + "description": "Etiqueta de la configuración de idioma" + }, + "chooseLanguage": "Elegir idioma", + "@chooseLanguage": { + "description": "Título del diálogo de selección de idioma" + }, + "save": "Guardar", + "@save": { + "description": "Etiqueta del botón de guardar" + }, + "cancel": "Cancelar", + "@cancel": { + "description": "Etiqueta del botón de cancelar" + }, + "close": "Cerrar", + "@close": { + "description": "Etiqueta del botón de cerrar" + }, + "about": "Acerca de", + "@about": { + "description": "Encabezado de la sección acerca de" + }, + "appVersion": "Versión de la aplicación", + "@appVersion": { + "description": "Etiqueta de la versión de la aplicación" + }, + "aboutMeshCoreSar": "Acerca de MeshCore SAR", + "@aboutMeshCoreSar": { + "description": "Título del diálogo acerca de" + }, + "aboutDescription": "Una aplicación de Búsqueda y Rescate diseñada para equipos de respuesta a emergencias. Las características incluyen:\n\n• Red mesh BLE para comunicación entre dispositivos\n• Mapas sin conexión con múltiples opciones de capas\n• Seguimiento en tiempo real de miembros del equipo\n• Marcadores tácticos SAR (persona encontrada, fuego, zona de preparación)\n• Gestión de contactos y mensajería\n• Seguimiento GPS con rumbo de brújula\n• Caché de teselas de mapa para uso sin conexión", + "@aboutDescription": { + "description": "Descripción del diálogo acerca de" + }, + "technologiesUsed": "Tecnologías utilizadas:", + "@technologiesUsed": { + "description": "Título de la sección de tecnologías utilizadas" + }, + "technologiesList": "• Flutter para desarrollo multiplataforma\n• BLE (Bluetooth Low Energy) para redes mesh\n• OpenStreetMap para mapas\n• Provider para gestión de estado\n• SharedPreferences para almacenamiento local", + "@technologiesList": { + "description": "Lista de tecnologías utilizadas" + }, + "moreInfo": "Más información", + "@moreInfo": { + "description": "Etiqueta del botón Más información" + }, + "packageName": "Nombre del paquete", + "@packageName": { + "description": "Etiqueta del nombre del paquete" + }, + "sampleData": "Datos de muestra", + "@sampleData": { + "description": "Encabezado de la sección de datos de muestra" + }, + "sampleDataDescription": "Cargar o borrar contactos de muestra, mensajes de canal y marcadores SAR para pruebas", + "@sampleDataDescription": { + "description": "Descripción de la sección de datos de muestra" + }, + "loadSampleData": "Cargar datos de muestra", + "@loadSampleData": { + "description": "Botón de cargar datos de muestra" + }, + "clearAllData": "Borrar todos los datos", + "@clearAllData": { + "description": "Botón de borrar todos los datos" + }, + "clearAllDataConfirmTitle": "Borrar todos los datos", + "@clearAllDataConfirmTitle": { + "description": "Título del diálogo de confirmación de borrar datos" + }, + "clearAllDataConfirmMessage": "Esto borrará todos los contactos y marcadores SAR. ¿Estás seguro?", + "@clearAllDataConfirmMessage": { + "description": "Mensaje de confirmación de borrar datos" + }, + "clear": "Borrar", + "@clear": { + "description": "Etiqueta del botón de borrar" + }, + "loadedSampleData": "Cargados {teamCount} miembros del equipo, {channelCount} canales, {sarCount} marcadores SAR, {messageCount} mensajes", + "@loadedSampleData": { + "description": "Mensaje de éxito después de cargar datos de muestra", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "Error al cargar datos de muestra: {error}", + "@failedToLoadSampleData": { + "description": "Mensaje de error cuando fallan los datos de muestra", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "Todos los datos borrados", + "@allDataCleared": { + "description": "Mensaje de éxito después de borrar todos los datos" + }, + "failedToStartBackgroundTracking": "Error al iniciar el seguimiento en segundo plano. Verifica los permisos y la conexión BLE.", + "@failedToStartBackgroundTracking": { + "description": "Mensaje de error cuando falla el inicio del seguimiento en segundo plano" + }, + "locationBroadcast": "Difusión de ubicación: {latitude}, {longitude}", + "@locationBroadcast": { + "description": "Mensaje de éxito para difusión de ubicación", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "El PIN predeterminado para dispositivos sin pantalla es 123456. ¿Problemas para emparejar? Olvida el dispositivo bluetooth en la configuración del sistema.", + "@defaultPinInfo": { + "description": "Información sobre el PIN predeterminado para emparejamiento" + }, + "noMessagesYet": "Aún no hay mensajes", + "@noMessagesYet": { + "description": "Mensaje de estado vacío cuando no hay mensajes" + }, + "pullDownToSync": "Desliza hacia abajo para sincronizar mensajes", + "@pullDownToSync": { + "description": "Instrucción para deslizar hacia abajo para actualizar mensajes" + }, + "deleteContact": "Eliminar contacto", + "@deleteContact": { + "description": "Etiqueta de la acción de eliminar contacto" + }, + "delete": "Eliminar", + "@delete": { + "description": "Etiqueta del botón de eliminar" + }, + "viewOnMap": "Ver en el mapa", + "@viewOnMap": { + "description": "Acción para ver la ubicación del contacto en el mapa" + }, + "refresh": "Actualizar", + "@refresh": { + "description": "Etiqueta del botón de actualizar" + }, + "resetPath": "Restablecer ruta (Re-enrutar)", + "@resetPath": { + "description": "Acción para restablecer la ruta del contacto para re-enrutamiento" + }, + "copiedToClipboard": "{label} copiado al portapapeles", + "@copiedToClipboard": { + "description": "Mensaje de éxito cuando se copia un valor al portapapeles", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "Por favor, introduce una contraseña", + "@pleaseEnterPassword": { + "description": "Mensaje de validación para campo de contraseña vacío" + }, + "failedToSyncContacts": "Error al sincronizar contactos: {error}", + "@failedToSyncContacts": { + "description": "Mensaje de error cuando falla la sincronización de contactos", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "¡Inicio de sesión exitoso! Esperando mensajes de la sala...", + "@loggedInSuccessfully": { + "description": "Mensaje de éxito después de iniciar sesión en la sala exitosamente" + }, + "loginFailed": "Error de inicio de sesión - contraseña incorrecta", + "@loginFailed": { + "description": "Mensaje de error cuando falla el inicio de sesión en la sala" + }, + "loggingIn": "Iniciando sesión en {roomName}...", + "@loggingIn": { + "description": "Mensaje de estado durante el proceso de inicio de sesión en la sala", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "Error al enviar inicio de sesión: {error}", + "@failedToSendLogin": { + "description": "Mensaje de error cuando falla el envío del comando de inicio de sesión", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "Baja precisión de ubicación", + "@lowLocationAccuracy": { + "description": "Título de advertencia para baja precisión GPS" + }, + "continue_": "Continuar", + "@continue_": { + "description": "Etiqueta del botón de continuar" + }, + "sendSarMarker": "Enviar marcador SAR", + "@sendSarMarker": { + "description": "Acción para enviar marcador SAR" + }, + "deleteDrawing": "Eliminar dibujo", + "@deleteDrawing": { + "description": "Acción para eliminar un dibujo del mapa" + }, + "drawingTools": "Herramientas de Dibujo", + "@drawingTools": { + "description": "Sección de herramientas de dibujo o título del menú" + }, + "drawLine": "Dibujar línea", + "@drawLine": { + "description": "Modo de dibujo del mapa: línea" + }, + "drawLineDesc": "Dibujar una línea a mano alzada en el mapa", + "@drawLineDesc": { + "description": "Descripción del modo de dibujo de línea" + }, + "drawRectangle": "Dibujar rectángulo", + "@drawRectangle": { + "description": "Modo de dibujo del mapa: rectángulo" + }, + "drawRectangleDesc": "Dibujar un área rectangular en el mapa", + "@drawRectangleDesc": { + "description": "Descripción del modo de dibujo de rectángulo" + }, + "measureDistance": "Medir distancia", + "@measureDistance": { + "description": "Modo de dibujo del mapa: medir distancia" + }, + "measureDistanceDesc": "Presión prolongada en dos puntos para medir", + "@measureDistanceDesc": { + "description": "Descripción del modo de medición de distancia" + }, + "clearMeasurement": "Borrar medición", + "@clearMeasurement": { + "description": "Tooltip para borrar la medición" + }, + "distanceLabel": "Distancia: {distance}", + "@distanceLabel": { + "description": "Etiqueta que muestra la distancia medida", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "Presión prolongada para el segundo punto", + "@longPressForSecondPoint": { + "description": "Instrucción cuando se ha establecido el primer punto de medición" + }, + "longPressToStartMeasurement": "Presión prolongada para establecer el primer punto", + "@longPressToStartMeasurement": { + "description": "Instrucción para comenzar la medición" + }, + "longPressToStartNewMeasurement": "Presión prolongada para nueva medición", + "@longPressToStartNewMeasurement": { + "description": "Instrucción para reiniciar la medición después de completarla" + }, + "shareDrawings": "Compartir dibujos", + "@shareDrawings": { + "description": "Acción para compartir dibujos a la red" + }, + "clearAllDrawings": "Borrar todos los dibujos", + "@clearAllDrawings": { + "description": "Acción para borrar todos los dibujos locales" + }, + "completeLine": "Completar línea", + "@completeLine": { + "description": "Tooltip para completar el dibujo de una línea" + }, + "broadcastDrawingsToTeam": "Transmitir {count} dibujo{plural} al equipo", + "@broadcastDrawingsToTeam": { + "description": "Subtítulo que muestra cuántos dibujos se transmitirán", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "Eliminar todos los {count} dibujo{plural}", + "@removeAllDrawings": { + "description": "Subtítulo para la acción de eliminar todos los dibujos", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "¿Eliminar todos los {count} dibujo{plural} del mapa?", + "@deleteAllDrawingsConfirm": { + "description": "Mensaje de confirmación para eliminar todos los dibujos", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "Dibujo", + "@drawing": { + "description": "Etiqueta genérica de dibujo" + }, + "shareDrawingsCount": "Compartir {count} dibujo{plural}", + "@shareDrawingsCount": { + "description": "Título para el diálogo de compartir dibujos", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "Mostrar dibujos recibidos", + "@showReceivedDrawings": { + "description": "Alternar para mostrar/ocultar dibujos recibidos de otros miembros del equipo" + }, + "showingAllDrawings": "Mostrando todos los dibujos", + "@showingAllDrawings": { + "description": "Subtítulo cuando los dibujos recibidos son visibles" + }, + "showingOnlyYourDrawings": "Mostrando solo tus dibujos", + "@showingOnlyYourDrawings": { + "description": "Subtítulo cuando los dibujos recibidos están ocultos" + }, + "showSarMarkers": "Mostrar marcadores SAR", + "@showSarMarkers": { + "description": "Alternar para mostrar/ocultar marcadores SAR en el mapa" + }, + "showingSarMarkers": "Mostrando marcadores SAR", + "@showingSarMarkers": { + "description": "Subtítulo cuando los marcadores SAR son visibles" + }, + "hidingSarMarkers": "Ocultando marcadores SAR", + "@hidingSarMarkers": { + "description": "Subtítulo cuando los marcadores SAR están ocultos" + }, + "clearAll": "Borrar todo", + "@clearAll": { + "description": "Etiqueta del botón de borrar todo" + }, + "publicChannel": "Canal público", + "@publicChannel": { + "description": "Opción de canal público para compartir" + }, + "broadcastToAll": "Difundir a todos los nodos cercanos (efímero)", + "@broadcastToAll": { + "description": "Descripción de la difusión de canal público" + }, + "storedPermanently": "Almacenado permanentemente en la sala", + "@storedPermanently": { + "description": "Descripción de la permanencia del almacenamiento en la sala" + }, + "notConnectedToDevice": "No conectado al dispositivo", + "@notConnectedToDevice": { + "description": "Mensaje de error cuando el dispositivo no está conectado para mensajería directa" + }, + "typeYourMessage": "Escribe tu mensaje...", + "@typeYourMessage": { + "description": "Texto de marcador de posición para el campo de entrada de mensaje" + }, + "quickLocationMarker": "Marcador de ubicación rápida", + "@quickLocationMarker": { + "description": "Subtítulo para el encabezado de la hoja de marcador SAR" + }, + "markerType": "Tipo de marcador", + "@markerType": { + "description": "Etiqueta para la sección de selección de tipo de marcador" + }, + "sendTo": "Enviar a", + "@sendTo": { + "description": "Etiqueta para la sección de selección de destino" + }, + "noDestinationsAvailable": "No hay destinos disponibles.", + "@noDestinationsAvailable": { + "description": "Advertencia cuando no existen salas o canales" + }, + "selectDestination": "Seleccionar destino...", + "@selectDestination": { + "description": "Marcador de posición para menú desplegable de destino" + }, + "ephemeralBroadcastInfo": "Efímero: Solo difusión por el aire. No se almacena - los nodos deben estar en línea.", + "@ephemeralBroadcastInfo": { + "description": "Información sobre difusiones de canal efímeras" + }, + "persistentRoomInfo": "Persistente: Almacenado de manera inmutable en la sala. Se sincroniza automáticamente y se conserva sin conexión.", + "@persistentRoomInfo": { + "description": "Información sobre almacenamiento persistente en sala" + }, + "location": "Ubicación", + "@location": { + "description": "Etiqueta para la sección de ubicación" + }, + "fromMap": "Desde el mapa", + "@fromMap": { + "description": "Insignia que muestra que la ubicación es desde un toque en el mapa" + }, + "gettingLocation": "Obteniendo ubicación...", + "@gettingLocation": { + "description": "Mensaje de carga mientras se obtiene la ubicación GPS" + }, + "locationError": "Error de ubicación", + "@locationError": { + "description": "Título para mensajes de error de ubicación" + }, + "retry": "Reintentar", + "@retry": { + "description": "Etiqueta del botón de reintentar" + }, + "refreshLocation": "Actualizar ubicación", + "@refreshLocation": { + "description": "Tooltip para el botón de actualizar ubicación" + }, + "accuracyMeters": "Precisión: ±{accuracy}m", + "@accuracyMeters": { + "description": "Visualización de la precisión GPS en metros", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "Notas (opcional)", + "@notesOptional": { + "description": "Etiqueta para el campo de notas opcional" + }, + "addAdditionalInformation": "Agregar información adicional...", + "@addAdditionalInformation": { + "description": "Marcador de posición para el campo de notas" + }, + "lowAccuracyWarning": "La precisión de la ubicación es ±{accuracy}m. Esto puede no ser lo suficientemente preciso para operaciones SAR.\n\n¿Continuar de todos modos?", + "@lowAccuracyWarning": { + "description": "Contenido del diálogo de advertencia para baja precisión GPS", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "Iniciar sesión en la sala", + "@loginToRoom": { + "description": "Título del diálogo de inicio de sesión en la sala" + }, + "enterPasswordInfo": "Introduce la contraseña para acceder a esta sala. La contraseña se guardará para uso futuro.", + "@enterPasswordInfo": { + "description": "Información sobre la contraseña de la sala" + }, + "password": "Contraseña", + "@password": { + "description": "Etiqueta del campo de contraseña" + }, + "enterRoomPassword": "Introduce la contraseña de la sala", + "@enterRoomPassword": { + "description": "Sugerencia del campo de contraseña" + }, + "loggingInDots": "Iniciando sesión...", + "@loggingInDots": { + "description": "Texto del botón mientras se inicia sesión" + }, + "login": "Iniciar sesión", + "@login": { + "description": "Etiqueta del botón de iniciar sesión" + }, + "failedToAddRoom": "Error al agregar la sala al dispositivo: {error}\n\nLa sala puede no haber anunciado aún.\nIntenta esperar a que la sala transmita.", + "@failedToAddRoom": { + "description": "Mensaje de error cuando falla la adición de la sala", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "Directo", + "@direct": { + "description": "Indicador de enrutamiento directo" + }, + "flood": "Inundación", + "@flood": { + "description": "Indicador de enrutamiento por inundación" + }, + "loggedIn": "Sesión iniciada", + "@loggedIn": { + "description": "Insignia de estado de sesión iniciada" + }, + "noGpsData": "Sin datos GPS", + "@noGpsData": { + "description": "Mensaje cuando los datos GPS no están disponibles" + }, + "distance": "Distancia", + "@distance": { + "description": "Etiqueta de distancia en la brújula" + }, + "directPingTimeout": "Tiempo de espera de ping directo agotado - reintentando {name} con inundación...", + "@directPingTimeout": { + "description": "Advertencia cuando el ping directo agota el tiempo de espera", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "Ping fallido a {name} - no se recibió respuesta", + "@pingFailed": { + "description": "Mensaje de error cuando falla el ping", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "¿Estás seguro de que quieres eliminar \"{name}\"?\n\nEsto eliminará el contacto tanto de la aplicación como del dispositivo de radio compañero.", + "@deleteContactConfirmation": { + "description": "Mensaje de confirmación para eliminar contacto", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "Error al eliminar contacto: {error}", + "@failedToRemoveContact": { + "description": "Mensaje de error cuando falla la eliminación del contacto", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "Tipo", + "@type": { + "description": "Etiqueta de tipo de contacto" + }, + "publicKey": "Clave pública", + "@publicKey": { + "description": "Etiqueta de clave pública" + }, + "lastSeen": "Visto por última vez", + "@lastSeen": { + "description": "Etiqueta de visto por última vez" + }, + "yes": "Sí", + "@yes": { + "description": "Respuesta sí" + }, + "no": "No", + "@no": { + "description": "Respuesta no" + }, + "permissions": "Permisos", + "@permissions": { + "description": "Etiqueta de permisos" + }, + "telemetry": "Telemetría", + "@telemetry": { + "description": "Encabezado de la sección de telemetría" + }, + "voltage": "Voltaje", + "@voltage": { + "description": "Etiqueta de voltaje" + }, + "battery": "Batería", + "@battery": { + "description": "Etiqueta de batería" + }, + "temperature": "Temperatura", + "@temperature": { + "description": "Etiqueta de temperatura" + }, + "humidity": "Humedad", + "@humidity": { + "description": "Etiqueta de humedad" + }, + "pressure": "Presión", + "@pressure": { + "description": "Etiqueta de presión" + }, + "gpsTelemetry": "GPS (Telemetría)", + "@gpsTelemetry": { + "description": "Etiqueta de GPS desde telemetría" + }, + "reLoginToRoom": "Re-iniciar sesión en la sala", + "@reLoginToRoom": { + "description": "Botón para re-iniciar sesión en la sala" + }, + "heading": "Rumbo", + "@heading": { + "description": "Etiqueta de rumbo de brújula" + }, + "elevation": "Elevación", + "@elevation": { + "description": "Etiqueta de elevación/altitud" + }, + "accuracy": "Precisión", + "@accuracy": { + "description": "Etiqueta de precisión GPS" + }, + "bearing": "Rumbo", + "@bearing": { + "description": "Etiqueta de rumbo en la brújula" + }, + "direction": "Dirección", + "@direction": { + "description": "Etiqueta de dirección en la brújula" + }, + "filterMarkers": "Filtrar marcadores", + "@filterMarkers": { + "description": "Título del diálogo de filtrar marcadores" + }, + "filterMarkersTooltip": "Filtrar marcadores", + "@filterMarkersTooltip": { + "description": "Tooltip para el botón de filtrar" + }, + "contactsFilter": "Contactos", + "@contactsFilter": { + "description": "Opción de filtro para contactos" + }, + "repeatersFilter": "Repetidores", + "@repeatersFilter": { + "description": "Opción de filtro para repetidores" + }, + "sarMarkers": "Marcadores SAR", + "@sarMarkers": { + "description": "Encabezado de la sección de marcadores SAR" + }, + "foundPerson": "Persona encontrada", + "@foundPerson": { + "description": "Tipo de marcador SAR de persona encontrada" + }, + "fire": "Fuego", + "@fire": { + "description": "Tipo de marcador SAR de fuego" + }, + "stagingArea": "Área de preparación", + "@stagingArea": { + "description": "Tipo de marcador SAR de área de preparación" + }, + "showAll": "Mostrar todo", + "@showAll": { + "description": "Botón para mostrar todos los filtros" + }, + "locationUnavailable": "Ubicación no disponible", + "@locationUnavailable": { + "description": "Mensaje cuando la ubicación GPS no está disponible" + }, + "ahead": "adelante", + "@ahead": { + "description": "Dirección de rumbo relativo - adelante" + }, + "degreesRight": "{degrees}° derecha", + "@degreesRight": { + "description": "Dirección de rumbo relativo - derecha", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "{degrees}° izquierda", + "@degreesLeft": { + "description": "Dirección de rumbo relativo - izquierda", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "Lat: {latitude} Lon: {longitude}", + "@latLonFormat": { + "description": "Formato de visualización de latitud y longitud", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "Aún no hay contactos", + "@noContactsYet": { + "description": "Mensaje de estado vacío cuando no hay contactos" + }, + "connectToDeviceToLoadContacts": "Conéctate a un dispositivo para cargar contactos", + "@connectToDeviceToLoadContacts": { + "description": "Instrucción para conectar dispositivo para cargar contactos" + }, + "teamMembers": "Miembros del equipo", + "@teamMembers": { + "description": "Encabezado de sección para miembros del equipo (contactos de chat)" + }, + "repeaters": "Repetidores", + "@repeaters": { + "description": "Encabezado de sección para nodos repetidores" + }, + "rooms": "Salas", + "@rooms": { + "description": "Encabezado de sección para salas" + }, + "channels": "Canales", + "@channels": { + "description": "Encabezado de sección para canales de difusión" + }, + "selectMapLayer": "Seleccionar capa de mapa", + "@selectMapLayer": { + "description": "Título del diálogo de selección de capa de mapa" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "Nombre de capa OpenStreetMap" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "Nombre de capa OpenTopoMap" + }, + "esriSatellite": "ESRI Satélite", + "@esriSatellite": { + "description": "Nombre de capa de imágenes de satélite ESRI" + }, + "googleHybrid": "Google Híbrido", + "@googleHybrid": { + "description": "Nombre de capa Google Híbrido (satélite + etiquetas)" + }, + "googleRoadmap": "Google Mapa de Carreteras", + "@googleRoadmap": { + "description": "Nombre de capa Google Mapa de Carreteras" + }, + "googleTerrain": "Google Terreno", + "@googleTerrain": { + "description": "Nombre de capa Google Terreno (topográfico)" + }, + "dragToPosition": "Arrastrar a posición", + "@dragToPosition": { + "description": "Etiqueta al arrastrar un pin en el mapa" + }, + "createSarMarker": "Crear marcador SAR", + "@createSarMarker": { + "description": "Etiqueta para crear marcador SAR desde pin" + }, + "compass": "Brújula", + "@compass": { + "description": "Título de brújula en diálogo de brújula detallada" + }, + "navigationAndContacts": "Navegación y contactos", + "@navigationAndContacts": { + "description": "Subtítulo del diálogo de brújula" + }, + "sarAlert": "ALERTA SAR", + "@sarAlert": { + "description": "Etiqueta de insignia de alerta SAR en mensajes" + }, + "textCopiedToClipboard": "Texto copiado al portapapeles", + "@textCopiedToClipboard": { + "description": "Mensaje de éxito cuando se copia texto" + }, + "cannotReplySenderMissing": "No se puede responder: falta información del remitente", + "@cannotReplySenderMissing": { + "description": "Error cuando falta información del remitente para responder" + }, + "cannotReplyContactNotFound": "No se puede responder: contacto no encontrado", + "@cannotReplyContactNotFound": { + "description": "Error cuando no se encuentra contacto para responder" + }, + "copyText": "Copiar texto", + "deleteMessage": "Eliminar mensaje", + "deleteMessageConfirmation": "¿Está seguro de que desea eliminar este mensaje?", + "shareLocation": "Compartir ubicación", + "shareLocationText": "{markerInfo}\n\nCoordenadas: {lat}, {lon}\n\nGoogle Maps: {url}", + "sarLocationShare": "Ubicación SAR", + "justNow": "Justo ahora", + "@justNow": { + "description": "Indicador de tiempo para actividad muy reciente" + }, + "minutesAgo": "Hace {minutes}m", + "@minutesAgo": { + "description": "Indicador de tiempo para hace minutos", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "Hace {hours}h", + "@hoursAgo": { + "description": "Indicador de tiempo para hace horas", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "Hace {days}d", + "@daysAgo": { + "description": "Indicador de tiempo para hace días", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "Hace {seconds}s", + "@secondsAgo": { + "description": "Indicador de tiempo para hace segundos", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "Enviando...", + "@sending": { + "description": "Estado de entrega: enviando" + }, + "sent": "Enviado", + "@sent": { + "description": "Estado de entrega: enviado" + }, + "delivered": "Entregado", + "@delivered": { + "description": "Estado de entrega: entregado" + }, + "deliveredWithTime": "Entregado ({time}ms)", + "@deliveredWithTime": { + "description": "Estado de entrega con tiempo de ida y vuelta", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "Fallido", + "@failed": { + "description": "Estado de entrega: fallido" + }, + "broadcast": "Difusión", + "@broadcast": { + "description": "Estado de entrega para mensajes de canal (sin ecos aún)" + }, + "deliveredToContacts": "Entregado a {delivered}/{total} contactos", + "@deliveredToContacts": { + "description": "Recuento de entrega de mensaje agrupado", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "Todo entregado", + "@allDelivered": { + "description": "Estado cuando todos los destinatarios recibieron el mensaje" + }, + "recipientDetails": "Detalles de destinatarios", + "@recipientDetails": { + "description": "Encabezado para lista de destinatarios expandible" + }, + "pending": "Pendiente", + "@pending": { + "description": "Estado de entrega: pendiente/esperando" + }, + "sarMarkerFoundPerson": "Persona encontrada", + "@sarMarkerFoundPerson": { + "description": "Tipo de marcador SAR: persona encontrada" + }, + "sarMarkerFire": "Ubicación de fuego", + "@sarMarkerFire": { + "description": "Tipo de marcador SAR: fuego" + }, + "sarMarkerStagingArea": "Área de preparación", + "@sarMarkerStagingArea": { + "description": "Tipo de marcador SAR: área de preparación" + }, + "sarMarkerObject": "Objeto encontrado", + "@sarMarkerObject": { + "description": "Tipo de marcador SAR: objeto" + }, + "from": "De", + "@from": { + "description": "Etiqueta de remitente en notificaciones" + }, + "coordinates": "Coordenadas", + "@coordinates": { + "description": "Etiqueta de coordenadas" + }, + "tapToViewOnMap": "Toca para ver en el mapa", + "@tapToViewOnMap": { + "description": "Texto de acción de notificación" + }, + "radioSettings": "Configuración de radio", + "@radioSettings": { + "description": "Título de sección para configuración de radio" + }, + "frequencyMHz": "Frecuencia (MHz)", + "@frequencyMHz": { + "description": "Etiqueta del campo de frecuencia de radio" + }, + "bandwidth": "Ancho de banda", + "@bandwidth": { + "description": "Etiqueta del menú desplegable de ancho de banda" + }, + "spreadingFactor": "Factor de dispersión", + "@spreadingFactor": { + "description": "Etiqueta del menú desplegable de factor de dispersión" + }, + "codingRate": "Tasa de codificación", + "@codingRate": { + "description": "Etiqueta del menú desplegable de tasa de codificación" + }, + "txPowerDbm": "Potencia TX (dBm)", + "@txPowerDbm": { + "description": "Etiqueta del campo de potencia TX" + }, + "maxPowerDbm": "Máx: {power} dBm", + "@maxPowerDbm": { + "description": "Texto de ayuda mostrando potencia TX máxima", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "Tú", + "@you": { + "description": "Etiqueta para el usuario actual en burbujas de mensaje" + }, + "exportFailed": "Error al exportar: {error}", + "@exportFailed": { + "description": "Mensaje de error cuando falla exportación", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "Error al importar: {error}", + "@importFailed": { + "description": "Mensaje de error cuando falla importación", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "Desconocido", + "@unknown": { + "description": "Etiqueta de valor desconocido" + }, + "onlineLayers": "Capas en línea", + "@onlineLayers": { + "description": "Encabezado de sección para capas de mapa en línea" + }, + "locationTrail": "Rastro de ubicación", + "@locationTrail": { + "description": "Título del rastro de ubicación" + }, + "showTrailOnMap": "Mostrar rastro en el mapa", + "@showTrailOnMap": { + "description": "Alternar para mostrar/ocultar rastro en el mapa" + }, + "trailVisible": "El rastro es visible en el mapa", + "@trailVisible": { + "description": "Estado de visibilidad del rastro - visible" + }, + "trailHiddenRecording": "El rastro está oculto (aún grabando)", + "@trailHiddenRecording": { + "description": "Estado de visibilidad del rastro - oculto pero grabando" + }, + "duration": "Duración", + "@duration": { + "description": "Etiqueta de duración" + }, + "points": "Puntos", + "@points": { + "description": "Etiqueta de recuento de puntos del rastro" + }, + "clearTrail": "Borrar rastro", + "@clearTrail": { + "description": "Botón para borrar rastro de ubicación" + }, + "clearTrailQuestion": "¿Borrar rastro?", + "@clearTrailQuestion": { + "description": "Título del diálogo de confirmación" + }, + "clearTrailConfirmation": "¿Estás seguro de que quieres borrar el rastro de ubicación actual? Esta acción no se puede deshacer.", + "@clearTrailConfirmation": { + "description": "Mensaje del diálogo de confirmación" + }, + "noTrailRecorded": "Aún no se ha grabado rastro", + "@noTrailRecorded": { + "description": "Mensaje cuando no existe rastro" + }, + "startTrackingToRecord": "Inicia el seguimiento de ubicación para grabar tu rastro", + "@startTrackingToRecord": { + "description": "Instrucciones para iniciar la grabación del rastro" + }, + "trailControls": "Controles del rastro", + "@trailControls": { + "description": "Tooltip de controles del rastro" + }, + "model": "Modelo", + "@model": { + "description": "Etiqueta del modelo del dispositivo" + }, + "version": "Versión", + "@version": { + "description": "Etiqueta de versión" + }, + "maxContacts": "Contactos máximos", + "@maxContacts": { + "description": "Etiqueta de capacidad máxima de contactos" + }, + "maxChannels": "Canales máximos", + "@maxChannels": { + "description": "Etiqueta de capacidad máxima de canales" + }, + "publicInfo": "Información pública", + "@publicInfo": { + "description": "Encabezado de la sección de información pública" + }, + "useCurrentLocation": "Usar ubicación actual", + "@useCurrentLocation": { + "description": "Tooltip para el botón de usar ubicación actual" + }, + "noneUnknown": "Ninguno/Desconocido", + "@noneUnknown": { + "description": "Tipo de dispositivo: ninguno o desconocido" + }, + "chatNode": "Nodo de chat", + "@chatNode": { + "description": "Tipo de dispositivo: nodo de chat" + }, + "repeater": "Repetidor", + "@repeater": { + "description": "Tipo de dispositivo: repetidor" + }, + "roomChannel": "Sala/Canal", + "@roomChannel": { + "description": "Tipo de dispositivo: sala o canal" + }, + "typeNumber": "Tipo {number}", + "@typeNumber": { + "description": "Tipo de dispositivo genérico con número", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "Error al guardar: {error}", + "@failedToSave": { + "description": "Mensaje de error genérico para fallos al guardar", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "Error al obtener ubicación: {error}", + "@failedToGetLocation": { + "description": "Mensaje de error cuando falla la obtención de ubicación", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "Plantillas SAR", + "addTemplate": "Agregar plantilla", + "editTemplate": "Editar plantilla", + "deleteTemplate": "Eliminar plantilla", + "templateName": "Nombre de plantilla", + "templateNameHint": "p. ej., Persona encontrada", + "templateEmoji": "Emoji", + "emojiRequired": "Se requiere emoji", + "nameRequired": "Se requiere nombre", + "templateDescription": "Descripcion (opcional)", + "templateDescriptionHint": "Anade contexto adicional...", + "templateColor": "Color", + "previewFormat": "Vista previa (formato de mensaje SAR)", + "importFromClipboard": "Importar", + "exportToClipboard": "Exportar", + "deleteTemplateConfirmation": "¿Eliminar la plantilla '{name}'?", + "templateAdded": "Plantilla anadida", + "templateUpdated": "Plantilla actualizada", + "templateDeleted": "Plantilla eliminada", + "templatesImported": "{count, plural, =0{No se importaron plantillas} =1{Se importo 1 plantilla} other{Se importaron {count} plantillas}}", + "templatesExported": "{count, plural, =1{Se exporto 1 plantilla al portapapeles} other{Se exportaron {count} plantillas al portapapeles}}", + "resetToDefaults": "Restablecer valores predeterminados", + "resetToDefaultsConfirmation": "Esto eliminará todas las plantillas personalizadas y restaurará las 4 plantillas predeterminadas. ¿Continuar?", + "reset": "Restablecer", + "resetComplete": "Plantillas restablecidas a valores predeterminados", + "noTemplates": "No hay plantillas disponibles", + "tapAddToCreate": "Toca + para crear tu primera plantilla", + "ok": "OK", + "locationPermission": "Permiso de ubicación", + "checking": "Comprobando...", + "locationPermissionGrantedAlways": "Concedido (Siempre)", + "locationPermissionGrantedWhileInUse": "Concedido (Durante el uso)", + "locationPermissionDeniedTapToRequest": "Denegado - Toca para solicitar", + "locationPermissionPermanentlyDeniedOpenSettings": "Denegado permanentemente - Abrir ajustes", + "locationPermissionDialogContent": "El permiso de ubicación está permanentemente denegado. Por favor, actívalo en la configuración de tu dispositivo para usar el rastreo GPS y compartir ubicación.", + "openSettings": "Abrir ajustes", + "locationPermissionGranted": "¡Permiso de ubicación concedido!", + "locationPermissionRequiredForGps": "El permiso de ubicación es necesario para el rastreo GPS y compartir ubicación.", + "locationPermissionAlreadyGranted": "El permiso de ubicación ya está concedido.", + "sarNavyBlue": "SAR Azul Marino", + "sarNavyBlueDescription": "Modo Profesional/Operaciones", + "selectRecipient": "Seleccionar destinatario", + "broadcastToAllNearby": "Transmitir a todos cercanos", + "searchRecipients": "Buscar destinatarios...", + "noContactsFound": "No se encontraron contactos", + "noRoomsFound": "No se encontraron salas", + "noRecipientsAvailable": "No hay destinatarios disponibles", + "noChannelsFound": "No se encontraron canales", + "newMessage": "Nuevo mensaje", + "channel": "Canal", + "samplePoliceLead": "Jefe de Policía", + "sampleDroneOperator": "Operador de Dron", + "sampleFirefighterAlpha": "Bombero", + "sampleMedicCharlie": "Médico", + "sampleCommandDelta": "Comando", + "sampleFireEngine": "Camión de Bomberos", + "sampleAirSupport": "Apoyo Aéreo", + "sampleBaseCoordinator": "Coordinador de Base", + "channelEmergency": "Emergencia", + "channelCoordination": "Coordinación", + "channelUpdates": "Actualizaciones", + "sampleTeamMember": "Miembro de Equipo de Muestra", + "sampleScout": "Explorador de Muestra", + "sampleBase": "Base de Muestra", + "sampleSearcher": "Buscador de Muestra", + "sampleObjectBackpack": " Mochila encontrada - color azul", + "sampleObjectVehicle": " Vehículo abandonado - verificar propietario", + "sampleObjectCamping": " Equipo de camping descubierto", + "sampleObjectTrailMarker": " Marcador de sendero encontrado fuera del camino", + "sampleMsgAllTeamsCheckIn": "Todos los equipos reporten", + "sampleMsgWeatherUpdate": "Actualización del clima: Cielo despejado, temp 18°C", + "sampleMsgBaseCamp": "Campamento base establecido en área de preparación", + "sampleMsgTeamAlpha": "Equipo moviéndose al sector 2", + "sampleMsgRadioCheck": "Prueba de radio - todas las estaciones respondan", + "sampleMsgWaterSupply": "Suministro de agua disponible en punto de control 3", + "sampleMsgTeamBravo": "Equipo reportando: sector 1 despejado", + "sampleMsgEtaRallyPoint": "ETA al punto de encuentro: 15 minutos", + "sampleMsgSupplyDrop": "Caída de suministros confirmada para las 14:00", + "sampleMsgDroneSurvey": "Inspección con dron completada - sin hallazgos", + "sampleMsgTeamCharlie": "Equipo solicitando apoyo", + "sampleMsgRadioDiscipline": "Todas las unidades: mantener disciplina de radio", + "sampleMsgUrgentMedical": "URGENTE: Asistencia médica necesaria en sector 4", + "sampleMsgAdultMale": " Hombre adulto, consciente", + "sampleMsgFireSpotted": "Fuego avistado - coordenadas próximas", + "sampleMsgSpreadingRapidly": " ¡Se propaga rápidamente!", + "sampleMsgPriorityHelicopter": "PRIORIDAD: Necesitamos apoyo de helicóptero", + "sampleMsgMedicalTeamEnRoute": "Equipo médico en camino a su ubicación", + "sampleMsgEvacHelicopter": "Helicóptero de evacuación ETA 10 minutos", + "sampleMsgEmergencyResolved": "Emergencia resuelta - todo despejado", + "sampleMsgEmergencyStagingArea": " Área de preparación de emergencia", + "sampleMsgEmergencyServices": "Servicios de emergencia notificados y respondiendo", + "sampleAlphaTeamLead": "Líder de Equipo", + "sampleBravoScout": "Explorador", + "sampleCharlieMedic": "Médico", + "sampleDeltaNavigator": "Navegador", + "sampleEchoSupport": "Apoyo", + "sampleBaseCommand": "Comando de Base", + "sampleFieldCoordinator": "Coordinador de Campo", + "sampleMedicalTeam": "Equipo Médico", + "mapDrawing": "Dibujo del Mapa", + "lineDrawing": "Línea", + "rectangleDrawing": "Rectángulo", + "navigateToDrawing": "Navegar al Dibujo", + "hideFromMap": "Ocultar del Mapa", + "copyCoordinates": "Copiar Coordenadas", + "manualCoordinates": "Coordenadas Manuales", + "enterCoordinatesManually": "Introducir coordenadas manualmente", + "latitudeLabel": "Latitud", + "longitudeLabel": "Longitud", + "exampleCoordinates": "Ejemplo: 46.0569, 14.5058", + "shareDrawing": "Compartir Dibujo", + "shareWithAllNearbyDevices": "Compartir con todos los dispositivos cercanos", + "shareToRoom": "Compartir en Sala", + "sendToPersistentStorage": "Enviar a almacenamiento persistente de sala", + "deleteDrawingConfirm": "¿Está seguro de que desea eliminar este dibujo?", + "drawingDeleted": "Dibujo eliminado", + "yourDrawingsCount": "Sus Dibujos ({count})", + "shared": "Compartido", + "line": "Línea", + "rectangle": "Rectángulo", + "saveAsTemplate": "Guardar como Plantilla", + "templateSaved": "Plantilla guardada exitosamente", + "templateAlreadyExists": "Ya existe una plantilla con este emoji", + "updateAvailable": "Actualización Disponible", + "currentVersion": "Actual", + "latestVersion": "Última", + "downloadUpdate": "Descargar", + "updateLater": "Más Tarde", + "cadastralParcels": "Parcelas Catastrales", + "forestRoads": "Caminos Forestales", + "wmsOverlays": "Superposiciones WMS", + "hikingTrails": "Senderos de Montaña", + "mainRoads": "Carreteras Principales", + "houseNumbers": "Números de Casa", + "fireHazardZones": "Zonas de Riesgo de Incendio", + "historicalFires": "Incendios Históricos", + "firebreaks": "Cortafuegos", + "krasFireZones": "Zonas de Incendio Kras", + "placeNames": "Nombres de Lugares", + "municipalityBorders": "Límites Municipales", + "topographicMap": "Mapa Topográfico 1:25000", + "recentMessages": "Mensajes Recientes", + "addChannel": "Agregar Canal", + "channelName": "Nombre del Canal", + "channelNameHint": "ej. Equipo de Rescate Alfa", + "channelSecret": "Contraseña del Canal", + "channelSecretHint": "Contraseña compartida para este canal", + "channelSecretHelp": "Esta contraseña debe compartirse con todos los miembros del equipo que necesiten acceso a este canal", + "channelTypesInfo": "Canales hash (#equipo): Contraseña generada automáticamente del nombre. Mismo nombre = mismo canal en todos los dispositivos.\n\nCanales privados: Use contraseña explícita. Solo aquellos con la contraseña pueden unirse.", + "hashChannelInfo": "Canal hash: La contraseña se generará automáticamente del nombre del canal. Cualquiera que use el mismo nombre se unirá al mismo canal.", + "channelNameRequired": "El nombre del canal es obligatorio", + "channelNameTooLong": "El nombre del canal debe tener 31 caracteres o menos", + "channelSecretRequired": "La contraseña del canal es obligatoria", + "channelSecretTooLong": "La contraseña del canal debe tener 32 caracteres o menos", + "invalidAsciiCharacters": "Solo se permiten caracteres ASCII", + "channelCreatedSuccessfully": "Canal creado exitosamente", + "channelCreationFailed": "Error al crear el canal: {error}", + "deleteChannel": "Eliminar Canal", + "deleteChannelConfirmation": "¿Está seguro de que desea eliminar el canal \"{channelName}\"? Esta acción no se puede deshacer.", + "channelDeletedSuccessfully": "Canal eliminado exitosamente", + "channelDeletionFailed": "Error al eliminar el canal: {error}", + "createChannel": "Crear Canal", + "wizardBack": "Atrás", + "wizardSkip": "Omitir", + "wizardNext": "Siguiente", + "wizardGetStarted": "Comenzar", + "wizardWelcomeTitle": "Bienvenido a MeshCore SAR", + "viewWelcomeTutorial": "Ver tutorial de bienvenida", + "allTeamContacts": "Todos los contactos del equipo", + "directMessagesInfo": "Mensajes directos con confirmaciones. Enviado a {count} miembros del equipo.", + "sarMarkerSentToContacts": "Marcador SAR enviado a {count} contactos", + "noContactsAvailable": "No hay contactos del equipo disponibles", + "reply": "Responder", + "technicalDetails": "Detalles técnicos", + "messageTechnicalDetails": "Detalles técnicos del mensaje", + "linkQuality": "Calidad del enlace", + "delivery": "Entrega", + "status": "Estado", + "expectedAckTag": "Etiqueta ACK esperada", + "roundTrip": "Ida y vuelta", + "retryAttempt": "Intento de reenvío", + "floodFallback": "Reserva de inundación", + "identity": "Identidad", + "messageId": "ID de mensaje", + "sender": "Remitente", + "senderKey": "Clave del remitente", + "recipient": "Destinatario", + "recipientKey": "Clave del destinatario", + "voice": "Voz", + "voiceId": "ID de voz", + "envelope": "Envolvente", + "sessionProgress": "Progreso de sesión", + "complete": "Completo", + "rawDump": "Volcado sin procesar", + "cannotRetryMissingRecipient": "No se puede reintentar: falta información del destinatario", + "voiceUnavailable": "Voz no disponible en este momento", + "requestingVoice": "Solicitando voz", + "device": "dispositivo", + "change": "Cambiar", + "wizardOverviewDescription": "Esta aplicación reúne mensajería MeshCore, actualizaciones SAR de campo, mapas y herramientas del dispositivo en un solo lugar.", + "wizardOverviewFeature1": "Envía mensajes directos, publicaciones en salas y mensajes de canal desde la pestaña principal Mensajes.", + "wizardOverviewFeature2": "Comparte marcadores SAR, dibujos del mapa, clips de voz e imágenes a través de la malla.", + "wizardOverviewFeature3": "Conéctate por BLE o TCP y luego gestiona la radio complementaria desde la aplicación.", + "wizardMessagingTitle": "Mensajería e informes de campo", + "wizardMessagingDescription": "Aquí los mensajes son más que texto plano. La aplicación ya admite varias cargas operativas y flujos de transferencia.", + "wizardMessagingFeature1": "Envía mensajes directos, publicaciones en salas y tráfico de canal desde un solo editor.", + "wizardMessagingFeature2": "Crea actualizaciones SAR y plantillas SAR reutilizables para informes de campo habituales.", + "wizardMessagingFeature3": "Transfiere sesiones de voz e imágenes con progreso y estimaciones de tiempo de aire en la interfaz.", + "wizardConnectDeviceTitle": "Conectar dispositivo", + "wizardConnectDeviceDescription": "Conecta tu radio MeshCore, elige un nombre y aplica un ajuste predefinido antes de continuar.", + "wizardSetupBadge": "Configuración", + "wizardOverviewBadge": "Resumen", + "wizardConnectedToDevice": "Conectado a {deviceName}", + "wizardNoDeviceConnected": "Aún no hay ningún dispositivo conectado", + "wizardSkipForNow": "Omitir por ahora", + "wizardDeviceNameLabel": "Nombre del dispositivo", + "wizardDeviceNameHelp": "Este nombre se anuncia a otros usuarios de MeshCore.", + "wizardConfigRegionLabel": "Región de configuración", + "wizardConfigRegionHelp": "Usa la lista oficial completa de ajustes predefinidos de MeshCore. El valor predeterminado es EU/UK (Narrow).", + "wizardPresetNote1": "Asegúrate de que el ajuste seleccionado coincida con la normativa local de radio.", + "wizardPresetNote2": "La lista coincide con la fuente oficial de ajustes predefinidos de la herramienta de configuración de MeshCore.", + "wizardPresetNote3": "EU/UK (Narrow) sigue seleccionado por defecto durante la incorporación.", + "wizardSaving": "Guardando...", + "wizardSaveAndContinue": "Guardar y continuar", + "wizardEnterDeviceName": "Introduce un nombre de dispositivo antes de continuar.", + "wizardDeviceSetupSaved": "Se guardó {deviceName} con {presetName}.", + "wizardNetworkTitle": "Contactos, salas y repetidores", + "wizardNetworkDescription": "La pestaña Contactos organiza la red que descubres y las rutas que aprendes con el tiempo.", + "wizardNetworkFeature1": "Revisa miembros del equipo, repetidores, salas, canales y anuncios pendientes en una sola lista.", + "wizardNetworkFeature2": "Usa ping inteligente, acceso a salas, rutas aprendidas y herramientas de reinicio de ruta cuando la conectividad se complique.", + "wizardNetworkFeature3": "Crea canales y gestiona destinos de red sin salir de la aplicación.", + "wizardMapOpsTitle": "Mapa, rastros y geometría compartida", + "wizardMapOpsDescription": "El mapa de la aplicación está vinculado directamente a la mensajería, el seguimiento y las superposiciones SAR en lugar de ser un visor aparte.", + "wizardMapOpsFeature1": "Sigue tu propia posición, las ubicaciones de tus compañeros y los rastros de movimiento en el mapa.", + "wizardMapOpsFeature2": "Abre dibujos desde mensajes, prévisualos en línea y elimínalos del mapa cuando sea necesario.", + "wizardMapOpsFeature3": "Usa vistas de mapa de repetidores y superposiciones compartidas para comprender el alcance de la red sobre el terreno.", + "wizardToolsTitle": "Herramientas más allá de la mensajería", + "wizardToolsDescription": "Aquí hay más que las cuatro pestañas principales. La aplicación también incluye configuración, diagnóstico y flujos opcionales de sensores.", + "wizardToolsFeature1": "Abre la configuración del dispositivo para cambiar ajustes de radio, telemetría, potencia TX y detalles del equipo complementario.", + "wizardToolsFeature2": "Activa la pestaña Sensores cuando quieras paneles de sensores vigilados y acciones de actualización rápida.", + "wizardToolsFeature3": "Usa registros de paquetes, escaneo de espectro y diagnósticos de desarrollador al solucionar problemas de la malla.", + "contactInSensors": "En Sensores", + "contactAddToSensors": "Añadir a Sensores", + "contactSetPath": "Definir ruta", + "contactAddedToSensors": "{contactName} se añadió a Sensores", + "contactFailedToClearRoute": "No se pudo borrar la ruta: {error}", + "contactRouteCleared": "Ruta borrada", + "contactRouteSet": "Ruta establecida: {route}", + "contactFailedToSetRoute": "No se pudo establecer la ruta: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "Tiempo de espera ACK", + "opcode": "Código de operación", + "payload": "Carga útil", + "hops": "Saltos", + "hashSize": "Tamaño del hash", + "pathBytes": "Bytes de ruta", + "selectedPath": "Ruta seleccionada", + "estimatedTx": "Transmisión estimada", + "senderToReceipt": "Del remitente al recibo", + "receivedCopies": "Copias recibidas", + "retryCause": "Causa del reintento", + "retryMode": "Modo de reintento", + "retryResult": "Resultado del reintento", + "lastRetry": "Último reintento", + "rxPackets": "Paquetes RX", + "mesh": "Mesh", + "rate": "Velocidad", + "window": "Ventana", + "posttxDelay": "Retardo post-tx", + "bandpass": "Paso de banda", + "bandpassFilterVoice": "Filtro de paso de banda de voz", + "active": "Activo", + "addContact": "Añadir contacto", + "all": "Todo", + "clearAllLabel": "Borrar todo", + "clearFilters": "Borrar filtros", + "clearRoute": "Borrar ruta", + "clearMessages": "Borrar mensajes", + "clearScale": "Borrar escala", + "clearDiscoveries": "Borrar descubrimientos", + "clearOnlineTraceDatabase": "Borrar base de datos de trazas", + "clearAllChannels": "Borrar todos los canales", + "clearAllContacts": "Borrar todos los contactos", + "clearChannels": "Borrar canales", + "clearContacts": "Borrar contactos", + "clearPathOnMaxRetry": "Borrar ruta en máximo reintento", + "create": "Crear", + "custom": "Personalizado", + "defaultValue": "Predeterminado", + "duplicate": "Duplicar", + "editName": "Editar nombre", + "open": "Abrir", + "paste": "Pegar", + "preview": "Vista previa", + "remove": "Eliminar", + "rename": "Renombrar", + "resolveAll": "Resolver todo", + "send": "Enviar", + "sendAnyway": "Enviar de todos modos", + "share": "Compartir", + "trace": "Traza", + "discovery": "Descubrimiento", + "discoverRepeaters": "Descubrir repetidores", + "discoverSensors": "Descubrir sensores", + "repeaterDiscoverySent": "Descubrimiento de repetidores enviado", + "sensorDiscoverySent": "Descubrimiento de sensores enviado", + "clearedPendingDiscoveries": "Descubrimientos pendientes borrados.", + "autoDiscovery": "Descubrimiento automático", + "enableAutomaticAdding": "Habilitar adición automática", + "autoaddRepeaters": "Añadir repetidores automáticamente", + "autoaddRoomServers": "Añadir servidores de sala automáticamente", + "autoaddSensors": "Añadir sensores automáticamente", + "autoaddUsers": "Añadir usuarios automáticamente", + "overwriteOldestWhenFull": "Sobrescribir más antiguos cuando esté lleno", + "storage": "Almacenamiento", + "dangerZone": "Zona de peligro", + "profiles": "Perfiles", + "favourites": "Favoritos", + "sensors": "Sensores", + "others": "Otros", + "gpsModule": "Módulo GPS", + "liveTraffic": "Tráfico en vivo", + "repeatersMap": "Mapa de repetidores", + "blePacketLogs": "Registros de paquetes BLE", + "onlineTraceDatabase": "Base de datos de trazas", + "routePathByteSize": "Tamaño en bytes de la ruta", + "messageNotifications": "Notificaciones de mensajes", + "sarAlerts": "Alertas SAR", + "discoveryNotifications": "Notificaciones de descubrimiento", + "updateNotifications": "Notificaciones de actualización", + "muteWhileAppIsOpen": "Silenciar con la app abierta", + "disableContacts": "Desactivar contactos", + "enableSensorsTab": "Habilitar pestaña Sensores", + "enableProfiles": "Habilitar perfiles", + "nearestRepeaterFallback": "Repetidor más cercano como reserva", + "deleteAllStoredMessageHistory": "Eliminar todo el historial de mensajes", + "messageFontSize": "Tamaño de fuente de mensajes", + "rotateMapWithHeading": "Rotar mapa con rumbo", + "showMapDebugInfo": "Mostrar info de depuración del mapa", + "openMapInFullscreen": "Abrir mapa en pantalla completa", + "showSarMarkersLabel": "Mostrar marcadores SAR", + "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", + "voiceLimiter": "Limitador de voz", + "micAutoGain": "Ganancia automática del micrófono", + "echoCancellation": "Cancelación de eco", + "noiseSuppression": "Supresión de ruido", + "trimSilenceInVoiceMessages": "Recortar silencio en mensajes de voz", + "compressor": "Compresor", + "limiter": "Limitador", + "autoGain": "Ganancia automática", + "echoCancel": "Eco", + "noiseSuppress": "Ruido", + "silenceTrim": "Silencio", + "maxImageSize": "Tamaño máximo de imagen", + "imageCompression": "Compresión de imagen", + "grayscale": "Escala de grises", + "ultraMode": "Modo ultra", + "fastPrivateGpsUpdates": "Actualizaciones GPS privadas rápidas", + "movementThreshold": "Umbral de movimiento", + "fastGpsMovementThreshold": "Umbral de movimiento GPS rápido", + "fastGpsActiveuseInterval": "Intervalo de uso activo GPS rápido", + "activeuseUpdateInterval": "Intervalo de actualización de uso activo", + "repeatNearbyTraffic": "Repetir tráfico cercano", + "relayThroughRepeatersAcrossTheMesh": "Retransmitir a través de repetidores en la red", + "nearbyOnlyWithoutRepeaterFlooding": "Solo cercanos, sin inundación de repetidores", + "multihop": "Multi-salto", + "createProfile": "Crear perfil", + "renameProfile": "Renombrar perfil", + "newProfile": "Nuevo perfil", + "manageProfiles": "Gestionar perfiles", + "enableProfilesToStartManagingThem": "Habilite los perfiles para comenzar a gestionarlos.", + "openMessage": "Abrir mensaje", + "jumpToTheRelatedSarMessage": "Ir al mensaje SAR relacionado", + "removeSarMarker": "Eliminar marcador SAR", + "pleaseSelectADestinationToSendSarMarker": "Seleccione un destino para enviar el marcador SAR", + "sarMarkerBroadcastToPublicChannel": "Marcador SAR transmitido al canal público", + "sarMarkerSentToRoom": "Marcador SAR enviado a la sala", + "loadFromGallery": "Cargar desde galería", + "replaceImage": "Reemplazar imagen", + "selectFromGallery": "Seleccionar de galería", + "team": "Equipo", + "found": "Encontrado", + "staging": "Área de reunión", + "object": "Objeto", + "quiet": "Silencioso", + "busy": "Ocupado", + "searchMessages": "Buscar mensajes", + "sendImageFromGallery": "Enviar imagen desde galería", + "takePhoto": "Tomar foto", + "allMessages": "Todos los mensajes", + "sendToPublicChannel": "¿Enviar al canal público?", + "selectMarkerTypeAndDestination": "Seleccione tipo de marcador y destino", + "noDestinationsAvailableLabel": "No hay destinos disponibles", + "image": "Imagen", + "format": "Formato", + "dimensions": "Dimensiones", + "segments": "Segmentos", + "transfers": "Transferencias", + "downloadedBy": "Descargado por", + "saveDiscoverySettings": "Guardar ajustes de descubrimiento", + "savePublicInfo": "Guardar información pública", + "saveRadioSettings": "Guardar ajustes de radio", + "savePath": "Guardar ruta", + "wipeDeviceData": "Borrar datos del dispositivo", + "wipeDevice": "Borrar dispositivo", + "destructiveDeviceActions": "Acciones destructivas del dispositivo.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Elija un preajuste o ajuste la configuración de radio.", + "used": "Usado", + "total": "Total", + "renameValue": "Renombrar valor", + "customizeFields": "Personalizar campos", + "livePreview": "Vista previa en vivo", + "refreshSchedule": "Programación de actualización", + "noResponse": "Sin respuesta", + "refreshing": "Actualizando", + "unavailable": "No disponible", + "pickARelayOrNodeToWatchInSensors": "Elija un relay o nodo para observar.", + "publicKeyLabel": "Clave pública", + "alreadyInContacts": "Ya en contactos", + "connectToADeviceBeforeAddingContacts": "Conéctese a un dispositivo antes de añadir contactos", + "fromContacts": "De contactos", + "onlineOnly": "Solo en línea", + "inBoth": "En ambos", + "source": "Fuente", + "allMessagesCleared": "Todos los mensajes borrados", + "onlineTraceDatabaseCleared": "Base de datos de trazas borrada", + "packetLogsCleared": "Registros de paquetes borrados", + "hexDataCopiedToClipboard": "Datos hex copiados al portapapeles", + "developerModeEnabled": "Modo desarrollador activado", + "developerModeDisabled": "Modo desarrollador desactivado", + "clipboardIsEmpty": "El portapapeles está vacío", + "contactImported": "Contacto importado", + "contactLinkCopiedToClipboard": "Enlace de contacto copiado al portapapeles", + "failedToExportContact": "Error al exportar el contacto", + "noLogsToExport": "No hay registros para exportar", + "exportAsCsv": "Exportar como CSV", + "exportAsText": "Exportar como texto", + "receivedRfc3339": "Recibido (RFC3339)", + "buildTime": "Hora de compilación", + "downloadUrlNotAvailable": "URL de descarga no disponible", + "cannotOpenDownloadUrl": "No se puede abrir la URL de descarga", + "updateCheckIsOnlyAvailableOnAndroid": "La comprobación de actualizaciones solo está disponible en Android", + "youAreRunningTheLatestVersion": "Está ejecutando la última versión", + "updateAvailableButDownloadUrlNotFound": "Actualización disponible pero URL de descarga no encontrada", + "startTictactoe": "Iniciar Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe no disponible", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: oponente desconocido", + "tictactoeWaitingForStart": "Tic-Tac-Toe: esperando inicio", + "acceptsShareLinks": "Acepta enlaces compartidos", + "supportsRawHex": "Soporta hex sin procesar", + "clipboardfriendly": "Compatible con portapapeles", + "captured": "Capturado", + "size": "Tamaño", + "noCustomChannelsToClear": "No hay canales personalizados que borrar.", + "noDeviceContactsToClear": "No hay contactos del dispositivo que borrar.", + "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", + "simpleMode": "Modo simple", + "simpleModeDescription": "Solo chat y mapa con contactos favoritos", + "simpleModeRequiresLockedDestination": "Activa primero «Bloquear destino» con un canal para usar el modo simple", + "noFavouriteContacts": "Sin contactos favoritos", + "selectDestinationFirst": "Selecciona primero un canal, contacto o sala", + "recipientNoLongerAvailable": "El destinatario ya no está disponible", + "failedToSendMessage": "Error al enviar: {error}", + "tictactoeDirectOnly": "El tres en raya solo funciona en mensajes directos. Elige primero un contacto.", + "deviceKeyUnavailable": "La clave del dispositivo no está disponible", + "imageCompressionFailed": "Error al comprimir la imagen", + "imageFragmentationFailed": "Error al fragmentar la imagen", + "failedToAnnounceImage": "Error al anunciar la imagen", + "noRecipientSelected": "Ningún destinatario seleccionado", + "imageSendFailed": "Error al enviar la imagen", + "microphonePermissionRequiredForVoice": "Se necesita permiso de micrófono para los mensajes de voz", + "recordingDiscarded": "Grabación descartada", + "publicChannelMediaWarning": "Estás a punto de enviar {mediaType} al canal público. No es recomendable, porque cualquiera en la red mesh podría recibirlo. Elige un canal privado o etiquetado, salvo que sea lo que quieres.", + "selectPrivateChannelToShareLocation": "Selecciona un canal privado para compartir tu ubicación.", + "stopSharingMyLocation": "Dejar de compartir mi ubicación", + "shareMyLocation": "Compartir mi ubicación", + "stopRecording": "Detener grabación", + "recordVoice": "Grabar voz", + "moreActions": "Más acciones", + "noMessagesInFilter": "No hay mensajes en este filtro", + "noMatchesInFilter": "No hay coincidencias en este filtro", + "syncFailed": "Error de sincronización: {error}", + "invalidLink": "Enlace no válido", + "cannotOpenLink": "No se puede abrir el enlace", + "failedToOpenLink": "Error al abrir el enlace", + "failedToResendMessage": "Error al reenviar el mensaje", + "retryFailed": "Error al reintentar: {error}", + "deleteDrawingAndMessage": "Eliminar dibujo y mensaje", + "lastEchoRelay": "Último eco: repetidor", + "lastEchoPath": "Último eco: ruta", + "lastEchoBytesReport": "Último eco: informe de bytes", + "noGpsCoordinatesAvailable": "No hay coordenadas GPS disponibles", + "notASarMarker": "No es un marcador SAR", + "failedToSaveTemplate": "Error al guardar la plantilla: {error}", + "drawingNotFound": "Dibujo no encontrado", + "unknownDrawingType": "Tipo de dibujo desconocido", + "customMapMarker": "Marcador de mapa personalizado", + "customMapPointLabel": "Punto: {point}", + "mapIdLabel": "ID del mapa: {id}", + "cannotFetchVoice": "No se puede obtener la voz", + "senderContactUnknown": "El contacto del remitente es desconocido. Sincroniza primero los contactos.", + "senderRouteUnknown": "La ruta del remitente es desconocida. Sincroniza primero los contactos/la ruta.", + "messageTooFar": "El mensaje está demasiado lejos ({hops} saltos, máx. {maxHops}).", + "senderRouteNoPathResponse": "La ruta del remitente no respondió a la comprobación de ruta. Sincroniza los contactos/la ruta e inténtalo de nuevo.", + "senderRouteNoRawResponse": "La ruta del remitente no respondió en la ruta de transporte raw.", + "voiceFetchOverHops": "Obtener la voz a través de {hops} saltos puede tardar un poco.", + "voiceReceiveCanceled": "Recepción de voz cancelada", + "fetchingMissingVoiceFragments": "Obteniendo fragmentos de voz que faltan", + "receivingVoice": "Recibiendo voz", + "cannotFetchImage": "No se puede obtener la imagen", + "imageFetchOverHops": "Obtener la imagen a través de {hops} saltos puede tardar un poco.", + "imageAlreadyBeingReceived": "La imagen ya se está recibiendo", + "loadImage": "Cargar imagen", + "imageFetchFailedToSendRequest": "No se pudo enviar la solicitud de imagen", + "imageUnavailable": "Imagen no disponible por ahora", + "imageFetchTimedOut": "La obtención de la imagen agotó el tiempo de espera", + "imageReceiveCanceled": "Recepción de imagen cancelada", + "closeImagePreview": "Cerrar vista previa de imagen", + "recordingReleaseToSend": "Grabando... suelta para enviar la voz", + "sendingVoice": "Enviando voz...", + "sendLongPressToRecordVoice": "Enviar (mantén pulsado para grabar voz)", + "discardRecording": "Descartar grabación", + "hopCount": "{count, plural, =1{1 salto} other{{count} saltos}}", + "channelsSectionDescription": "Canales de difusión para el tráfico mesh cercano", + "roomsSectionDescription": "Espacios compartidos para la coordinación del equipo", + "contactsSectionDescription": "Personas y dispositivos a tu alcance directo", + "noRecentChatters": "Sin participantes recientes", + "public": "Público", + "failedToSendTicTacToeMove": "No se pudo enviar el movimiento de tres en raya", + "youWon": "Has ganado", + "opponentWon": "Ha ganado el rival", + "gameDraw": "Empate", + "yourTurn": "Te toca", + "opponentTurn": "Le toca al rival", + "pleaseEnterValidCoordinates": "Introduce coordenadas válidas", + "locationNotAvailable": "Ubicación no disponible", + "customCaveMapPoint": "Punto de mapa de cueva personalizado", + "mapPoint": "Punto del mapa", + "fetchingMissingImageFragments": "Obteniendo fragmentos que faltan…", + "loadingImage": "Cargando…", + "receivingImage": "Recibiendo…", + "tapToLoad": "Toca para cargar", + "connectDevice": "Conectar dispositivo", + "chooseTransportSubtitle": "Elige el transporte Bluetooth, WiFi o Serie", + "ble": "BLE", + "network": "Red", + "serial": "Serie", + "scan": "Buscar", + "pressScanToSearchForDevices": "Pulsa Buscar para encontrar dispositivos cercanos", + "failedToConnectToDevice": "Error al conectar con {name}", + "unknownDevice": "Dispositivo desconocido", + "showingCachedResultsTapRefresh": "Mostrando resultados en caché. Toca actualizar para volver a buscar.", + "scanningLocalNetworkOnPort": "Buscando dispositivos MeshCore WiFi en la red local en el puerto {port}", + "cancelScanAndAddServer": "Cancelar búsqueda y añadir servidor", + "addServer": "Añadir servidor", + "scanningProgressIps": "Buscando... {scanned}/{total} IP", + "cancelAndEnterManually": "Cancelar e introducir manualmente", + "noRecentOrDiscoveredServers": "Aún no hay servidores recientes ni descubiertos", + "recentlyUsed": "Usados recientemente", + "connecting": "Conectando...", + "discoveredOnThisNetwork": "Descubiertos en esta red", + "serverNoLongerAvailable": "El servidor en {host}:{port} ya no está disponible. Vuelve a buscar para encontrar servidores activos.", + "failedToConnectToHost": "Error al conectar con {host}:{port}", + "enterValidIpAddress": "Introduce una dirección IP válida", + "enterValidTcpPort": "Introduce un puerto TCP válido", + "ipAddress": "Dirección IP", + "tcpPort": "Puerto TCP", + "customServerPort": "Puerto de servidor personalizado", + "chat": "Chat", + "restoringPreviousLink": "Restaurando el enlace anterior", + "noDeviceConnected": "Ningún dispositivo conectado", + "sendAdvert": "Enviar mi contacto", + "advertMode": "Enviar mi contacto", + "advertModeSubtitle": "Elige hasta dónde debe llegar tu información de contacto.", + "floodAdvertSent": "Contacto enviado a través de la red mesh", + "directAdvertSent": "Contacto enviado a dispositivos cercanos" +} diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb new file mode 100644 index 0000000..dec4212 --- /dev/null +++ b/lib/l10n/app_fr.arb @@ -0,0 +1,2002 @@ +{ + "@@locale": "fr", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "Le titre de l'application" + }, + "messages": "Messages", + "@messages": { + "description": "Libellé de l'onglet Messages" + }, + "contacts": "Contacts", + "@contacts": { + "description": "Libellé de l'onglet Contacts" + }, + "map": "Carte", + "@map": { + "description": "Libellé de l'onglet Carte" + }, + "settings": "Paramètres", + "@settings": { + "description": "Titre de l'écran Paramètres" + }, + "connect": "Connecter", + "@connect": { + "description": "Libellé du bouton Connecter" + }, + "disconnect": "Déconnecter", + "@disconnect": { + "description": "Libellé du bouton Déconnecter" + }, + "noDevicesFound": "Aucun appareil trouvé", + "@noDevicesFound": { + "description": "Texte affiché lorsqu'aucun appareil BLE n'est trouvé" + }, + "scanAgain": "Rechercher à nouveau", + "@scanAgain": { + "description": "Bouton pour relancer la recherche BLE" + }, + "deviceNotConnected": "Appareil non connecté", + "@deviceNotConnected": { + "description": "Message d'erreur lorsque l'appareil n'est pas connecté" + }, + "locationPermissionDenied": "Permission de localisation refusée", + "@locationPermissionDenied": { + "description": "Erreur lorsque la permission de localisation est refusée" + }, + "locationPermissionPermanentlyDenied": "Permission de localisation définitivement refusée. Veuillez l'activer dans les Paramètres.", + "@locationPermissionPermanentlyDenied": { + "description": "Erreur lorsque la permission de localisation est définitivement refusée" + }, + "locationPermissionRequired": "La permission de localisation est requise pour le suivi GPS et la coordination d'équipe. Vous pouvez l'activer plus tard dans les Paramètres.", + "@locationPermissionRequired": { + "description": "Message lorsque la permission de localisation est nécessaire" + }, + "locationServicesDisabled": "Les services de localisation sont désactivés. Veuillez les activer dans les Paramètres.", + "@locationServicesDisabled": { + "description": "Erreur lorsque les services de localisation sont désactivés" + }, + "failedToGetGpsLocation": "Échec de l'obtention de la position GPS", + "@failedToGetGpsLocation": { + "description": "Erreur lorsque la position GPS ne peut pas être obtenue" + }, + "failedToAdvertise": "Échec de l'annonce : {error}", + "@failedToAdvertise": { + "description": "Message d'erreur pour l'échec de l'annonce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "Annuler la reconnexion", + "@cancelReconnection": { + "description": "Info-bulle pour le bouton d'annulation de reconnexion" + }, + "general": "Général", + "@general": { + "description": "En-tête de section des paramètres généraux" + }, + "theme": "Thème", + "@theme": { + "description": "Libellé du paramètre de thème" + }, + "chooseTheme": "Choisir le thème", + "@chooseTheme": { + "description": "Titre de la boîte de dialogue de sélection du thème" + }, + "light": "Clair", + "@light": { + "description": "Option de thème clair" + }, + "dark": "Sombre", + "@dark": { + "description": "Option de thème sombre" + }, + "blueLightTheme": "Thème bleu clair", + "@blueLightTheme": { + "description": "Description du thème bleu clair" + }, + "blueDarkTheme": "Thème bleu sombre", + "@blueDarkTheme": { + "description": "Description du thème bleu sombre" + }, + "sarRed": "SAR Rouge", + "@sarRed": { + "description": "Option de thème SAR Rouge" + }, + "alertEmergencyMode": "Mode alerte/urgence", + "@alertEmergencyMode": { + "description": "Description du thème SAR Rouge" + }, + "sarGreen": "SAR Vert", + "@sarGreen": { + "description": "Option de thème SAR Vert" + }, + "safeAllClearMode": "Mode sécurisé/dégagé", + "@safeAllClearMode": { + "description": "Description du thème SAR Vert" + }, + "autoSystem": "Auto (Système)", + "@autoSystem": { + "description": "Option de thème automatique/système" + }, + "followSystemTheme": "Suivre le thème du système", + "@followSystemTheme": { + "description": "Description du thème système" + }, + "showRxTxIndicators": "Afficher les indicateurs RX/TX", + "@showRxTxIndicators": { + "description": "Paramètre pour afficher les indicateurs RX/TX" + }, + "disableMap": "Désactiver la carte", + "@disableMap": { + "description": "Paramètre pour désactiver l'onglet carte" + }, + "language": "Langue", + "@language": { + "description": "Libellé du paramètre de langue" + }, + "chooseLanguage": "Choisir la langue", + "@chooseLanguage": { + "description": "Titre de la boîte de dialogue de sélection de langue" + }, + "save": "Enregistrer", + "@save": { + "description": "Libellé du bouton Enregistrer" + }, + "cancel": "Annuler", + "@cancel": { + "description": "Libellé du bouton Annuler" + }, + "close": "Fermer", + "@close": { + "description": "Libellé du bouton Fermer" + }, + "about": "À propos", + "@about": { + "description": "En-tête de section À propos" + }, + "appVersion": "Version de l'application", + "@appVersion": { + "description": "Libellé de la version de l'application" + }, + "aboutMeshCoreSar": "À propos de MeshCore SAR", + "@aboutMeshCoreSar": { + "description": "Titre de la boîte de dialogue À propos" + }, + "aboutDescription": "Une application de recherche et sauvetage conçue pour les équipes d'intervention d'urgence. Les fonctionnalités incluent :\n\n• Réseau maillé BLE pour communication appareil à appareil\n• Cartes hors ligne avec options de couches multiples\n• Suivi en temps réel des membres de l'équipe\n• Marqueurs tactiques SAR (personne trouvée, feu, zone de rassemblement)\n• Gestion des contacts et messagerie\n• Suivi GPS avec cap du compas\n• Mise en cache des tuiles de carte pour utilisation hors ligne", + "@aboutDescription": { + "description": "Description de la boîte de dialogue À propos" + }, + "technologiesUsed": "Technologies utilisées :", + "@technologiesUsed": { + "description": "Titre de la section des technologies utilisées" + }, + "technologiesList": "• Flutter pour le développement multiplateforme\n• BLE (Bluetooth Low Energy) pour réseau maillé\n• OpenStreetMap pour la cartographie\n• Provider pour la gestion d'état\n• SharedPreferences pour le stockage local", + "@technologiesList": { + "description": "Liste des technologies utilisées" + }, + "moreInfo": "Plus d'infos", + "@moreInfo": { + "description": "Libellé du bouton Plus d'infos" + }, + "packageName": "Nom du package", + "@packageName": { + "description": "Libellé du nom du package" + }, + "sampleData": "Données d'exemple", + "@sampleData": { + "description": "En-tête de section Données d'exemple" + }, + "sampleDataDescription": "Charger ou effacer les contacts d'exemple, les messages de canal et les marqueurs SAR pour les tests", + "@sampleDataDescription": { + "description": "Description de la section Données d'exemple" + }, + "loadSampleData": "Charger des données d'exemple", + "@loadSampleData": { + "description": "Bouton pour charger des données d'exemple" + }, + "clearAllData": "Effacer toutes les données", + "@clearAllData": { + "description": "Bouton pour effacer toutes les données" + }, + "clearAllDataConfirmTitle": "Effacer toutes les données", + "@clearAllDataConfirmTitle": { + "description": "Titre de la boîte de dialogue de confirmation d'effacement des données" + }, + "clearAllDataConfirmMessage": "Cela effacera tous les contacts et marqueurs SAR. Êtes-vous sûr ?", + "@clearAllDataConfirmMessage": { + "description": "Message de confirmation d'effacement des données" + }, + "clear": "Effacer", + "@clear": { + "description": "Libellé du bouton Effacer" + }, + "loadedSampleData": "Chargé {teamCount} membres d'équipe, {channelCount} canaux, {sarCount} marqueurs SAR, {messageCount} messages", + "@loadedSampleData": { + "description": "Message de succès après le chargement des données d'exemple", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "Échec du chargement des données d'exemple : {error}", + "@failedToLoadSampleData": { + "description": "Message d'erreur lorsque le chargement des données d'exemple échoue", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "Toutes les données effacées", + "@allDataCleared": { + "description": "Message de succès après l'effacement de toutes les données" + }, + "failedToStartBackgroundTracking": "Échec du démarrage du suivi en arrière-plan. Vérifiez les permissions et la connexion BLE.", + "@failedToStartBackgroundTracking": { + "description": "Message d'erreur lorsque le suivi en arrière-plan échoue au démarrage" + }, + "locationBroadcast": "Diffusion de position : {latitude}, {longitude}", + "@locationBroadcast": { + "description": "Message de succès pour la diffusion de position", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "Le code PIN par défaut pour les appareils sans écran est 123456. Problèmes d'appairage ? Oubliez l'appareil Bluetooth dans les paramètres système.", + "@defaultPinInfo": { + "description": "Information sur le code PIN par défaut pour l'appairage" + }, + "noMessagesYet": "Aucun message pour le moment", + "@noMessagesYet": { + "description": "Message d'état vide lorsqu'il n'y a pas de messages" + }, + "pullDownToSync": "Tirez vers le bas pour synchroniser les messages", + "@pullDownToSync": { + "description": "Instruction pour tirer vers le bas pour actualiser les messages" + }, + "deleteContact": "Supprimer le contact", + "@deleteContact": { + "description": "Libellé de l'action de suppression de contact" + }, + "delete": "Supprimer", + "@delete": { + "description": "Libellé du bouton Supprimer" + }, + "viewOnMap": "Voir sur la carte", + "@viewOnMap": { + "description": "Action pour voir la position du contact sur la carte" + }, + "refresh": "Actualiser", + "@refresh": { + "description": "Libellé du bouton Actualiser" + }, + "resetPath": "Réinitialiser le chemin (Re-router)", + "@resetPath": { + "description": "Action pour réinitialiser le chemin du contact pour réacheminement" + }, + "copiedToClipboard": "{label} copié dans le presse-papiers", + "@copiedToClipboard": { + "description": "Message de succès lorsqu'une valeur est copiée dans le presse-papiers", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "Veuillez saisir un mot de passe", + "@pleaseEnterPassword": { + "description": "Message de validation pour le champ de mot de passe vide" + }, + "failedToSyncContacts": "Échec de la synchronisation des contacts : {error}", + "@failedToSyncContacts": { + "description": "Message d'erreur lorsque la synchronisation des contacts échoue", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "Connexion réussie ! En attente des messages du salon...", + "@loggedInSuccessfully": { + "description": "Message de succès après une connexion au salon réussie" + }, + "loginFailed": "Échec de la connexion - mot de passe incorrect", + "@loginFailed": { + "description": "Message d'erreur lorsque la connexion au salon échoue" + }, + "loggingIn": "Connexion à {roomName}...", + "@loggingIn": { + "description": "Message d'état pendant le processus de connexion au salon", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "Échec de l'envoi de la connexion : {error}", + "@failedToSendLogin": { + "description": "Message d'erreur lorsque la commande de connexion échoue à l'envoi", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "Précision de localisation faible", + "@lowLocationAccuracy": { + "description": "Titre d'avertissement pour une faible précision GPS" + }, + "continue_": "Continuer", + "@continue_": { + "description": "Libellé du bouton Continuer" + }, + "sendSarMarker": "Envoyer un marqueur SAR", + "@sendSarMarker": { + "description": "Action pour envoyer un marqueur SAR" + }, + "deleteDrawing": "Supprimer le dessin", + "@deleteDrawing": { + "description": "Action pour supprimer un dessin de carte" + }, + "drawingTools": "Outils de Dessin", + "@drawingTools": { + "description": "Section des outils de dessin ou titre du menu" + }, + "drawLine": "Tracer une ligne", + "@drawLine": { + "description": "Mode de dessin de carte : ligne" + }, + "drawLineDesc": "Tracer une ligne à main levée sur la carte", + "@drawLineDesc": { + "description": "Description du mode de dessin de ligne" + }, + "drawRectangle": "Tracer un rectangle", + "@drawRectangle": { + "description": "Mode de dessin de carte : rectangle" + }, + "drawRectangleDesc": "Tracer une zone rectangulaire sur la carte", + "@drawRectangleDesc": { + "description": "Description du mode de dessin de rectangle" + }, + "measureDistance": "Mesurer la distance", + "@measureDistance": { + "description": "Mode de dessin de carte : mesurer la distance" + }, + "measureDistanceDesc": "Appui long sur deux points pour mesurer", + "@measureDistanceDesc": { + "description": "Description du mode de mesure de distance" + }, + "clearMeasurement": "Effacer la mesure", + "@clearMeasurement": { + "description": "Infobulle pour effacer la mesure" + }, + "distanceLabel": "Distance : {distance}", + "@distanceLabel": { + "description": "Étiquette affichant la distance mesurée", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "Appui long pour le deuxième point", + "@longPressForSecondPoint": { + "description": "Instruction lorsque le premier point de mesure est défini" + }, + "longPressToStartMeasurement": "Appui long pour définir le premier point", + "@longPressToStartMeasurement": { + "description": "Instruction pour commencer la mesure" + }, + "longPressToStartNewMeasurement": "Appui long pour nouvelle mesure", + "@longPressToStartNewMeasurement": { + "description": "Instruction pour redémarrer la mesure après achèvement" + }, + "shareDrawings": "Partager les dessins", + "@shareDrawings": { + "description": "Action pour partager les dessins sur le réseau" + }, + "clearAllDrawings": "Effacer tous les dessins", + "@clearAllDrawings": { + "description": "Action pour effacer tous les dessins locaux" + }, + "completeLine": "Terminer la ligne", + "@completeLine": { + "description": "Tooltip pour terminer le tracé d'une ligne" + }, + "broadcastDrawingsToTeam": "Diffuser {count} dessin{plural} à l'équipe", + "@broadcastDrawingsToTeam": { + "description": "Sous-titre indiquant combien de dessins seront diffusés", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "Supprimer tous les {count} dessin{plural}", + "@removeAllDrawings": { + "description": "Sous-titre pour l'action de suppression de tous les dessins", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "Supprimer tous les {count} dessin{plural} de la carte ?", + "@deleteAllDrawingsConfirm": { + "description": "Message de confirmation pour supprimer tous les dessins", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "Dessin", + "@drawing": { + "description": "Étiquette de dessin générique" + }, + "shareDrawingsCount": "Partager {count} dessin{plural}", + "@shareDrawingsCount": { + "description": "Titre pour le dialogue de partage de dessins", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "Afficher les dessins reçus", + "@showReceivedDrawings": { + "description": "Basculer pour afficher/masquer les dessins reçus des autres membres de l'équipe" + }, + "showingAllDrawings": "Affichage de tous les dessins", + "@showingAllDrawings": { + "description": "Sous-titre lorsque les dessins reçus sont visibles" + }, + "showingOnlyYourDrawings": "Affichage uniquement de vos dessins", + "@showingOnlyYourDrawings": { + "description": "Sous-titre lorsque les dessins reçus sont masqués" + }, + "showSarMarkers": "Afficher les marqueurs SAR", + "@showSarMarkers": { + "description": "Basculer pour afficher/masquer les marqueurs SAR sur la carte" + }, + "showingSarMarkers": "Affichage des marqueurs SAR", + "@showingSarMarkers": { + "description": "Sous-titre lorsque les marqueurs SAR sont visibles" + }, + "hidingSarMarkers": "Masquage des marqueurs SAR", + "@hidingSarMarkers": { + "description": "Sous-titre lorsque les marqueurs SAR sont masqués" + }, + "clearAll": "Tout effacer", + "@clearAll": { + "description": "Libellé du bouton Tout effacer" + }, + "publicChannel": "Canal public", + "@publicChannel": { + "description": "Option de canal public pour le partage" + }, + "broadcastToAll": "Diffuser à tous les nœuds à proximité (éphémère)", + "@broadcastToAll": { + "description": "Description de la diffusion du canal public" + }, + "storedPermanently": "Stocké de manière permanente dans le salon", + "@storedPermanently": { + "description": "Description de la permanence du stockage dans le salon" + }, + "notConnectedToDevice": "Non connecté à l'appareil", + "@notConnectedToDevice": { + "description": "Message d'erreur lorsque l'appareil n'est pas connecté pour la messagerie directe" + }, + "typeYourMessage": "Saisissez votre message...", + "@typeYourMessage": { + "description": "Texte de l'espace réservé pour le champ de saisie de message" + }, + "quickLocationMarker": "Marqueur de position rapide", + "@quickLocationMarker": { + "description": "Sous-titre de l'en-tête de la feuille de marqueur SAR" + }, + "markerType": "Type de marqueur", + "@markerType": { + "description": "Libellé de la section de sélection du type de marqueur" + }, + "sendTo": "Envoyer à", + "@sendTo": { + "description": "Libellé de la section de sélection de destination" + }, + "noDestinationsAvailable": "Aucune destination disponible.", + "@noDestinationsAvailable": { + "description": "Avertissement lorsqu'il n'existe aucun salon ou canal" + }, + "selectDestination": "Sélectionner la destination...", + "@selectDestination": { + "description": "Espace réservé pour le menu déroulant de destination" + }, + "ephemeralBroadcastInfo": "Éphémère : Diffusion par ondes uniquement. Non stocké - les nœuds doivent être en ligne.", + "@ephemeralBroadcastInfo": { + "description": "Information sur les diffusions de canal éphémères" + }, + "persistentRoomInfo": "Persistant : Stocké de manière immuable dans le salon. Synchronisé automatiquement et préservé hors ligne.", + "@persistentRoomInfo": { + "description": "Information sur le stockage persistant dans le salon" + }, + "location": "Position", + "@location": { + "description": "Libellé de la section de position" + }, + "fromMap": "Depuis la carte", + "@fromMap": { + "description": "Badge indiquant que la position provient d'un clic sur la carte" + }, + "gettingLocation": "Obtention de la position...", + "@gettingLocation": { + "description": "Message de chargement lors de l'obtention de la position GPS" + }, + "locationError": "Erreur de localisation", + "@locationError": { + "description": "Titre des messages d'erreur de localisation" + }, + "retry": "Réessayer", + "@retry": { + "description": "Libellé du bouton Réessayer" + }, + "refreshLocation": "Actualiser la position", + "@refreshLocation": { + "description": "Info-bulle pour le bouton d'actualisation de la position" + }, + "accuracyMeters": "Précision : ±{accuracy}m", + "@accuracyMeters": { + "description": "Affichage de la précision GPS en mètres", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "Notes (facultatives)", + "@notesOptional": { + "description": "Libellé du champ de notes facultatives" + }, + "addAdditionalInformation": "Ajouter des informations supplémentaires...", + "@addAdditionalInformation": { + "description": "Espace réservé pour le champ de notes" + }, + "lowAccuracyWarning": "La précision de localisation est de ±{accuracy}m. Cela peut ne pas être assez précis pour les opérations SAR.\n\nContinuer quand même ?", + "@lowAccuracyWarning": { + "description": "Contenu de la boîte de dialogue d'avertissement pour une faible précision GPS", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "Se connecter au salon", + "@loginToRoom": { + "description": "Titre de la boîte de dialogue de connexion au salon" + }, + "enterPasswordInfo": "Entrez le mot de passe pour accéder à ce salon. Le mot de passe sera enregistré pour une utilisation future.", + "@enterPasswordInfo": { + "description": "Information sur le mot de passe du salon" + }, + "password": "Mot de passe", + "@password": { + "description": "Libellé du champ de mot de passe" + }, + "enterRoomPassword": "Entrez le mot de passe du salon", + "@enterRoomPassword": { + "description": "Indice du champ de mot de passe" + }, + "loggingInDots": "Connexion...", + "@loggingInDots": { + "description": "Texte du bouton pendant la connexion" + }, + "login": "Se connecter", + "@login": { + "description": "Libellé du bouton de connexion" + }, + "failedToAddRoom": "Échec de l'ajout du salon à l'appareil : {error}\n\nLe salon n'a peut-être pas encore été annoncé.\nEssayez d'attendre que le salon diffuse.", + "@failedToAddRoom": { + "description": "Message d'erreur lorsque l'ajout du salon échoue", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "Direct", + "@direct": { + "description": "Indicateur de routage direct" + }, + "flood": "Inondation", + "@flood": { + "description": "Indicateur de routage par inondation" + }, + "loggedIn": "Connecté", + "@loggedIn": { + "description": "Badge d'état connecté" + }, + "noGpsData": "Aucune donnée GPS", + "@noGpsData": { + "description": "Message lorsque les données GPS ne sont pas disponibles" + }, + "distance": "Distance", + "@distance": { + "description": "Libellé de la distance" + }, + "directPingTimeout": "Délai d'attente du ping direct - nouvelle tentative de {name} avec inondation...", + "@directPingTimeout": { + "description": "Avertissement lorsque le ping direct expire", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "Échec du ping vers {name} - aucune réponse reçue", + "@pingFailed": { + "description": "Message d'erreur lorsque le ping échoue", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "Êtes-vous sûr de vouloir supprimer \"{name}\" ?\n\nCela supprimera le contact de l'application et de l'appareil radio compagnon.", + "@deleteContactConfirmation": { + "description": "Message de confirmation pour la suppression du contact", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "Échec de la suppression du contact : {error}", + "@failedToRemoveContact": { + "description": "Message d'erreur lorsque la suppression du contact échoue", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "Type", + "@type": { + "description": "Libellé du type de contact" + }, + "publicKey": "Clé publique", + "@publicKey": { + "description": "Libellé de la clé publique" + }, + "lastSeen": "Dernière vue", + "@lastSeen": { + "description": "Libellé de la dernière vue" + }, + "yes": "Oui", + "@yes": { + "description": "Réponse Oui" + }, + "no": "Non", + "@no": { + "description": "Réponse Non" + }, + "permissions": "Permissions", + "@permissions": { + "description": "Libellé des permissions" + }, + "telemetry": "Télémétrie", + "@telemetry": { + "description": "En-tête de section de télémétrie" + }, + "voltage": "Tension", + "@voltage": { + "description": "Libellé de la tension" + }, + "battery": "Batterie", + "@battery": { + "description": "Libellé de la batterie" + }, + "temperature": "Température", + "@temperature": { + "description": "Libellé de la température" + }, + "humidity": "Humidité", + "@humidity": { + "description": "Libellé de l'humidité" + }, + "pressure": "Pression", + "@pressure": { + "description": "Libellé de la pression" + }, + "gpsTelemetry": "GPS (Télémétrie)", + "@gpsTelemetry": { + "description": "Libellé du GPS depuis la télémétrie" + }, + "reLoginToRoom": "Se reconnecter au salon", + "@reLoginToRoom": { + "description": "Bouton pour se reconnecter au salon" + }, + "heading": "Cap", + "@heading": { + "description": "Libellé du cap du compas" + }, + "elevation": "Élévation", + "@elevation": { + "description": "Libellé de l'élévation/altitude" + }, + "accuracy": "Précision", + "@accuracy": { + "description": "Libellé de la précision GPS" + }, + "bearing": "Relèvement", + "@bearing": { + "description": "Libellé du relèvement dans la boussole" + }, + "direction": "Direction", + "@direction": { + "description": "Libellé de la direction dans la boussole" + }, + "filterMarkers": "Filtrer les marqueurs", + "@filterMarkers": { + "description": "Titre de la boîte de dialogue de filtrage des marqueurs" + }, + "filterMarkersTooltip": "Filtrer les marqueurs", + "@filterMarkersTooltip": { + "description": "Info-bulle du bouton de filtre" + }, + "contactsFilter": "Contacts", + "@contactsFilter": { + "description": "Option de filtre pour les contacts" + }, + "repeatersFilter": "Répéteurs", + "@repeatersFilter": { + "description": "Option de filtre pour les répéteurs" + }, + "sarMarkers": "Marqueurs SAR", + "@sarMarkers": { + "description": "En-tête de section des marqueurs SAR" + }, + "foundPerson": "Personne trouvée", + "@foundPerson": { + "description": "Type de marqueur SAR personne trouvée" + }, + "fire": "Feu", + "@fire": { + "description": "Type de marqueur SAR feu" + }, + "stagingArea": "Zone de rassemblement", + "@stagingArea": { + "description": "Type de marqueur SAR zone de rassemblement" + }, + "showAll": "Tout afficher", + "@showAll": { + "description": "Bouton pour afficher tous les filtres" + }, + "locationUnavailable": "Position non disponible", + "@locationUnavailable": { + "description": "Message lorsque la position GPS n'est pas disponible" + }, + "ahead": "devant", + "@ahead": { + "description": "Direction de relèvement relatif - devant" + }, + "degreesRight": "{degrees}° à droite", + "@degreesRight": { + "description": "Direction de relèvement relatif - à droite", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "{degrees}° à gauche", + "@degreesLeft": { + "description": "Direction de relèvement relatif - à gauche", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "Lat : {latitude} Lon : {longitude}", + "@latLonFormat": { + "description": "Format d'affichage de la latitude et de la longitude", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "Aucun contact pour le moment", + "@noContactsYet": { + "description": "Message d'état vide lorsqu'il n'y a pas de contacts" + }, + "connectToDeviceToLoadContacts": "Connectez-vous à un appareil pour charger les contacts", + "@connectToDeviceToLoadContacts": { + "description": "Instruction pour connecter l'appareil pour charger les contacts" + }, + "teamMembers": "Membres de l'équipe", + "@teamMembers": { + "description": "En-tête de section pour les membres de l'équipe (contacts de discussion)" + }, + "repeaters": "Répéteurs", + "@repeaters": { + "description": "En-tête de section pour les nœuds répéteurs" + }, + "rooms": "Salons", + "@rooms": { + "description": "En-tête de section pour les salons" + }, + "channels": "Canaux", + "@channels": { + "description": "En-tête de section pour les canaux de diffusion" + }, + "selectMapLayer": "Sélectionner la couche de carte", + "@selectMapLayer": { + "description": "Titre de la boîte de dialogue de sélection de la couche de carte" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "Nom de la couche OpenStreetMap" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "Nom de la couche OpenTopoMap" + }, + "esriSatellite": "Satellite ESRI", + "@esriSatellite": { + "description": "Nom de la couche d'imagerie satellite ESRI" + }, + "googleHybrid": "Google Hybride", + "@googleHybrid": { + "description": "Nom de la couche Google Hybride (satellite + étiquettes)" + }, + "googleRoadmap": "Google Carte Routière", + "@googleRoadmap": { + "description": "Nom de la couche Google Carte Routière" + }, + "googleTerrain": "Google Terrain", + "@googleTerrain": { + "description": "Nom de la couche Google Terrain (topographique)" + }, + "dragToPosition": "Faire glisser vers la position", + "@dragToPosition": { + "description": "Libellé lors du glissement d'une épingle sur la carte" + }, + "createSarMarker": "Créer un marqueur SAR", + "@createSarMarker": { + "description": "Libellé pour créer un marqueur SAR à partir d'une épingle" + }, + "compass": "Boussole", + "@compass": { + "description": "Titre de la boussole dans la boîte de dialogue de boussole détaillée" + }, + "navigationAndContacts": "Navigation et contacts", + "@navigationAndContacts": { + "description": "Sous-titre de la boîte de dialogue de la boussole" + }, + "sarAlert": "ALERTE SAR", + "@sarAlert": { + "description": "Libellé du badge d'alerte SAR sur les messages" + }, + "textCopiedToClipboard": "Texte copié dans le presse-papiers", + "@textCopiedToClipboard": { + "description": "Message de succès lorsque le texte est copié" + }, + "cannotReplySenderMissing": "Impossible de répondre : informations sur l'expéditeur manquantes", + "@cannotReplySenderMissing": { + "description": "Erreur lorsque les informations sur l'expéditeur manquent pour la réponse" + }, + "cannotReplyContactNotFound": "Impossible de répondre : contact non trouvé", + "@cannotReplyContactNotFound": { + "description": "Erreur lorsque le contact n'est pas trouvé pour la réponse" + }, + "copyText": "Copier le texte", + "deleteMessage": "Supprimer le message", + "deleteMessageConfirmation": "Êtes-vous sûr de vouloir supprimer ce message?", + "shareLocation": "Partager la position", + "shareLocationText": "{markerInfo}\n\nCoordonnées: {lat}, {lon}\n\nGoogle Maps: {url}", + "sarLocationShare": "Position SAR", + "justNow": "À l'instant", + "@justNow": { + "description": "Indicateur de temps pour une activité très récente" + }, + "minutesAgo": "Il y a {minutes}m", + "@minutesAgo": { + "description": "Indicateur de temps pour les minutes écoulées", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "Il y a {hours}h", + "@hoursAgo": { + "description": "Indicateur de temps pour les heures écoulées", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "Il y a {days}j", + "@daysAgo": { + "description": "Indicateur de temps pour les jours écoulés", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "Il y a {seconds}s", + "@secondsAgo": { + "description": "Indicateur de temps pour les secondes écoulées", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "Envoi...", + "@sending": { + "description": "État de livraison : envoi" + }, + "sent": "Envoyé", + "@sent": { + "description": "État de livraison : envoyé" + }, + "delivered": "Livré", + "@delivered": { + "description": "État de livraison : livré" + }, + "deliveredWithTime": "Livré ({time}ms)", + "@deliveredWithTime": { + "description": "État de livraison avec temps aller-retour", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "Échec", + "@failed": { + "description": "État de livraison : échec" + }, + "broadcast": "Diffusion", + "@broadcast": { + "description": "État de livraison pour les messages de canal (pas encore d'échos)" + }, + "deliveredToContacts": "Livré à {delivered}/{total} contacts", + "@deliveredToContacts": { + "description": "Nombre de livraisons de message groupé", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "Tout livré", + "@allDelivered": { + "description": "État lorsque tous les destinataires ont reçu le message" + }, + "recipientDetails": "Détails des destinataires", + "@recipientDetails": { + "description": "En-tête pour la liste déroulante des destinataires" + }, + "pending": "En attente", + "@pending": { + "description": "État de livraison : en attente" + }, + "sarMarkerFoundPerson": "Personne trouvée", + "@sarMarkerFoundPerson": { + "description": "Type de marqueur SAR : personne trouvée" + }, + "sarMarkerFire": "Lieu de feu", + "@sarMarkerFire": { + "description": "Type de marqueur SAR : feu" + }, + "sarMarkerStagingArea": "Zone de rassemblement", + "@sarMarkerStagingArea": { + "description": "Type de marqueur SAR : zone de rassemblement" + }, + "sarMarkerObject": "Objet trouvé", + "@sarMarkerObject": { + "description": "Type de marqueur SAR : objet" + }, + "from": "De", + "@from": { + "description": "Libellé de l'expéditeur dans les notifications" + }, + "coordinates": "Coordonnées", + "@coordinates": { + "description": "Libellé des coordonnées" + }, + "tapToViewOnMap": "Appuyez pour voir sur la carte", + "@tapToViewOnMap": { + "description": "Texte de l'action de notification" + }, + "radioSettings": "Paramètres radio", + "@radioSettings": { + "description": "Titre de section pour les paramètres radio" + }, + "frequencyMHz": "Fréquence (MHz)", + "@frequencyMHz": { + "description": "Libellé du champ de fréquence radio" + }, + "bandwidth": "Bande passante", + "@bandwidth": { + "description": "Libellé du menu déroulant de bande passante" + }, + "spreadingFactor": "Facteur d'étalement", + "@spreadingFactor": { + "description": "Libellé du menu déroulant du facteur d'étalement" + }, + "codingRate": "Taux de codage", + "@codingRate": { + "description": "Libellé du menu déroulant du taux de codage" + }, + "txPowerDbm": "Puissance TX (dBm)", + "@txPowerDbm": { + "description": "Libellé du champ de puissance TX" + }, + "maxPowerDbm": "Max : {power} dBm", + "@maxPowerDbm": { + "description": "Texte d'aide indiquant la puissance TX maximale", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "Vous", + "@you": { + "description": "Libellé pour l'utilisateur actuel dans les bulles de message" + }, + "exportFailed": "Echec de l'exportation : {error}", + "@exportFailed": { + "description": "Message d'erreur lors de l'échec de l'exportation", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "Echec de l'importation : {error}", + "@importFailed": { + "description": "Message d'erreur lors de l'échec de l'importation", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "Inconnu", + "@unknown": { + "description": "Libellé de valeur inconnue" + }, + "onlineLayers": "Couches en ligne", + "@onlineLayers": { + "description": "En-tête de section pour les couches de carte en ligne" + }, + "locationTrail": "Trace de déplacement", + "@locationTrail": { + "description": "Titre de la trace de déplacement" + }, + "showTrailOnMap": "Afficher la trace sur la carte", + "@showTrailOnMap": { + "description": "Interrupteur pour afficher/masquer la trace sur la carte" + }, + "trailVisible": "La trace est visible sur la carte", + "@trailVisible": { + "description": "État de visibilité de la trace - visible" + }, + "trailHiddenRecording": "La trace est masquée (enregistrement en cours)", + "@trailHiddenRecording": { + "description": "État de visibilité de la trace - masqué mais enregistrement en cours" + }, + "duration": "Durée", + "@duration": { + "description": "Libellé de la durée" + }, + "points": "Points", + "@points": { + "description": "Libellé du nombre de points de la trace" + }, + "clearTrail": "Effacer la trace", + "@clearTrail": { + "description": "Bouton pour effacer la trace de déplacement" + }, + "clearTrailQuestion": "Effacer la trace ?", + "@clearTrailQuestion": { + "description": "Titre de la boîte de dialogue de confirmation" + }, + "clearTrailConfirmation": "Êtes-vous sûr de vouloir effacer la trace de déplacement actuelle ? Cette action ne peut pas être annulée.", + "@clearTrailConfirmation": { + "description": "Message de la boîte de dialogue de confirmation" + }, + "noTrailRecorded": "Aucune trace enregistrée pour le moment", + "@noTrailRecorded": { + "description": "Message lorsqu'aucune trace n'existe" + }, + "startTrackingToRecord": "Démarrez le suivi de position pour enregistrer votre trace", + "@startTrackingToRecord": { + "description": "Instructions pour démarrer l'enregistrement de la trace" + }, + "trailControls": "Contrôles de la trace", + "@trailControls": { + "description": "Info-bulle des contrôles de la trace" + }, + "model": "Modèle", + "@model": { + "description": "Libellé du modèle d'appareil" + }, + "version": "Version", + "@version": { + "description": "Libellé de la version" + }, + "maxContacts": "Contacts max", + "@maxContacts": { + "description": "Libellé de la capacité maximale de contacts" + }, + "maxChannels": "Canaux max", + "@maxChannels": { + "description": "Libellé de la capacité maximale de canaux" + }, + "publicInfo": "Informations publiques", + "@publicInfo": { + "description": "En-tête de section des informations publiques" + }, + "useCurrentLocation": "Utiliser la position actuelle", + "@useCurrentLocation": { + "description": "Info-bulle du bouton d'utilisation de la position actuelle" + }, + "noneUnknown": "Aucun/Inconnu", + "@noneUnknown": { + "description": "Type d'appareil : aucun ou inconnu" + }, + "chatNode": "Nœud de discussion", + "@chatNode": { + "description": "Type d'appareil : nœud de discussion" + }, + "repeater": "Répéteur", + "@repeater": { + "description": "Type d'appareil : répéteur" + }, + "roomChannel": "Salon/Canal", + "@roomChannel": { + "description": "Type d'appareil : salon ou canal" + }, + "typeNumber": "Type {number}", + "@typeNumber": { + "description": "Type d'appareil générique avec numéro", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "Échec de l'enregistrement : {error}", + "@failedToSave": { + "description": "Message d'erreur générique pour les échecs d'enregistrement", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "Échec de l'obtention de la position : {error}", + "@failedToGetLocation": { + "description": "Message d'erreur lorsque l'obtention de la position échoue", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "Modèles SAR", + "addTemplate": "Ajouter un modèle", + "editTemplate": "Modifier le modèle", + "deleteTemplate": "Supprimer le modèle", + "templateName": "Nom du modèle", + "templateNameHint": "p. ex. Personne trouvee", + "templateEmoji": "Emoji", + "emojiRequired": "Emoji est requis", + "nameRequired": "Nom est requis", + "templateDescription": "Description (facultative)", + "templateDescriptionHint": "Ajoutez un contexte supplementaire...", + "templateColor": "Couleur", + "previewFormat": "Apercu (format de message SAR)", + "importFromClipboard": "Importer", + "exportToClipboard": "Exporter", + "deleteTemplateConfirmation": "Supprimer le modele '{name}' ?", + "templateAdded": "Modele ajoute", + "templateUpdated": "Modele mis a jour", + "templateDeleted": "Modele supprime", + "templatesImported": "{count, plural, =0{Aucun modele importe} =1{1 modele importe} other{{count} modeles importes}}", + "templatesExported": "{count, plural, =1{1 modele exporte vers le presse-papiers} other{{count} modeles exportes vers le presse-papiers}}", + "resetToDefaults": "Réinitialiser aux valeurs par défaut", + "resetToDefaultsConfirmation": "Cela supprimera tous les modèles personnalisés et restaurera les 4 modèles par défaut. Continuer?", + "reset": "Réinitialiser", + "resetComplete": "Modèles réinitialisés aux valeurs par défaut", + "noTemplates": "Aucun modele disponible", + "tapAddToCreate": "Appuyez sur + pour creer votre premier modele", + "ok": "OK", + "locationPermission": "Autorisation de localisation", + "checking": "Vérification...", + "locationPermissionGrantedAlways": "Accordée (Toujours)", + "locationPermissionGrantedWhileInUse": "Accordée (En cours d'utilisation)", + "locationPermissionDeniedTapToRequest": "Refusée - Appuyez pour demander", + "locationPermissionPermanentlyDeniedOpenSettings": "Refusée définitivement - Ouvrir les paramètres", + "locationPermissionDialogContent": "L'autorisation de localisation est définitivement refusée. Veuillez l'activer dans les paramètres de votre appareil pour utiliser le suivi GPS et le partage de localisation.", + "openSettings": "Ouvrir les paramètres", + "locationPermissionGranted": "Autorisation de localisation accordée !", + "locationPermissionRequiredForGps": "L'autorisation de localisation est nécessaire pour le suivi GPS et le partage de localisation.", + "locationPermissionAlreadyGranted": "L'autorisation de localisation est déjà accordée.", + "sarNavyBlue": "SAR Bleu Marine", + "sarNavyBlueDescription": "Mode Professionnel/Opérations", + "selectRecipient": "Sélectionner le destinataire", + "broadcastToAllNearby": "Diffuser à tous à proximité", + "searchRecipients": "Rechercher des destinataires...", + "noContactsFound": "Aucun contact trouvé", + "noRoomsFound": "Aucune salle trouvée", + "noRecipientsAvailable": "Aucun destinataire disponible", + "noChannelsFound": "Aucun canal trouvé", + "newMessage": "Nouveau message", + "channel": "Canal", + "samplePoliceLead": "Chef de Police", + "sampleDroneOperator": "Opérateur de Drone", + "sampleFirefighterAlpha": "Pompier", + "sampleMedicCharlie": "Médecin", + "sampleCommandDelta": "Commandement", + "sampleFireEngine": "Camion de Pompiers", + "sampleAirSupport": "Soutien Aérien", + "sampleBaseCoordinator": "Coordinateur de Base", + "channelEmergency": "Urgence", + "channelCoordination": "Coordination", + "channelUpdates": "Mises à jour", + "sampleTeamMember": "Membre d'Équipe Exemple", + "sampleScout": "Éclaireur Exemple", + "sampleBase": "Base Exemple", + "sampleSearcher": "Chercheur Exemple", + "sampleObjectBackpack": " Sac à dos trouvé - couleur bleue", + "sampleObjectVehicle": " Véhicule abandonné - vérifier le propriétaire", + "sampleObjectCamping": " Équipement de camping découvert", + "sampleObjectTrailMarker": " Balise de sentier trouvée hors piste", + "sampleMsgAllTeamsCheckIn": "Toutes les équipes se signaler", + "sampleMsgWeatherUpdate": "Mise à jour météo : Ciel dégagé, temp 18°C", + "sampleMsgBaseCamp": "Camp de base établi à la zone de rassemblement", + "sampleMsgTeamAlpha": "Équipe se déplaçant vers le secteur 2", + "sampleMsgRadioCheck": "Test radio - toutes les stations répondent", + "sampleMsgWaterSupply": "Approvisionnement en eau disponible au point de contrôle 3", + "sampleMsgTeamBravo": "Équipe signale : secteur 1 dégagé", + "sampleMsgEtaRallyPoint": "ETA au point de ralliement : 15 minutes", + "sampleMsgSupplyDrop": "Largage de ravitaillement confirmé pour 14h00", + "sampleMsgDroneSurvey": "Surveillance par drone terminée - aucune découverte", + "sampleMsgTeamCharlie": "Équipe demande du renfort", + "sampleMsgRadioDiscipline": "Toutes les unités : maintenir la discipline radio", + "sampleMsgUrgentMedical": "URGENT : Assistance médicale nécessaire au secteur 4", + "sampleMsgAdultMale": " Homme adulte, conscient", + "sampleMsgFireSpotted": "Feu repéré - coordonnées à venir", + "sampleMsgSpreadingRapidly": " Se propage rapidement !", + "sampleMsgPriorityHelicopter": "PRIORITÉ : Besoin de soutien hélicoptère", + "sampleMsgMedicalTeamEnRoute": "Équipe médicale en route vers votre position", + "sampleMsgEvacHelicopter": "Hélicoptère d'évacuation ETA 10 minutes", + "sampleMsgEmergencyResolved": "Urgence résolue - tout est clair", + "sampleMsgEmergencyStagingArea": " Zone de rassemblement d'urgence", + "sampleMsgEmergencyServices": "Services d'urgence notifiés et en réponse", + "sampleAlphaTeamLead": "Chef d'Équipe", + "sampleBravoScout": "Éclaireur", + "sampleCharlieMedic": "Médecin", + "sampleDeltaNavigator": "Navigateur", + "sampleEchoSupport": "Soutien", + "sampleBaseCommand": "Commandement de Base", + "sampleFieldCoordinator": "Coordinateur de Terrain", + "sampleMedicalTeam": "Équipe Médicale", + "mapDrawing": "Dessin de Carte", + "lineDrawing": "Ligne", + "rectangleDrawing": "Rectangle", + "navigateToDrawing": "Naviguer vers le Dessin", + "hideFromMap": "Masquer de la Carte", + "copyCoordinates": "Copier les Coordonnées", + "manualCoordinates": "Coordonnées Manuelles", + "enterCoordinatesManually": "Entrer les coordonnées manuellement", + "latitudeLabel": "Latitude", + "longitudeLabel": "Longitude", + "exampleCoordinates": "Exemple: 46.0569, 14.5058", + "shareDrawing": "Partager le Dessin", + "shareWithAllNearbyDevices": "Partager avec tous les appareils à proximité", + "shareToRoom": "Partager dans la Salle", + "sendToPersistentStorage": "Envoyer au stockage persistant de la salle", + "deleteDrawingConfirm": "Êtes-vous sûr de vouloir supprimer ce dessin?", + "drawingDeleted": "Dessin supprimé", + "yourDrawingsCount": "Vos Dessins ({count})", + "shared": "Partagé", + "line": "Ligne", + "rectangle": "Rectangle", + "saveAsTemplate": "Enregistrer comme Modèle", + "templateSaved": "Modèle enregistré avec succès", + "templateAlreadyExists": "Un modèle avec cet emoji existe déjà", + "updateAvailable": "Mise à Jour Disponible", + "currentVersion": "Actuelle", + "latestVersion": "Dernière", + "downloadUpdate": "Télécharger", + "updateLater": "Plus Tard", + "cadastralParcels": "Parcelles Cadastrales", + "forestRoads": "Chemins Forestiers", + "wmsOverlays": "Superpositions WMS", + "hikingTrails": "Sentiers de Randonnée", + "mainRoads": "Routes Principales", + "houseNumbers": "Numéros de Maison", + "fireHazardZones": "Zones à Risque d'Incendie", + "historicalFires": "Incendies Historiques", + "firebreaks": "Coupe-feu", + "krasFireZones": "Zones d'Incendie Kras", + "placeNames": "Noms de Lieux", + "municipalityBorders": "Limites Municipales", + "topographicMap": "Carte Topographique 1:25000", + "recentMessages": "Messages Récents", + "@recentMessages": { + "description": "Header for recent messages overlay on map in fullscreen mode" + }, + "addChannel": "Ajouter un Canal", + "channelName": "Nom du Canal", + "channelNameHint": "par ex. Équipe de Sauvetage Alpha", + "channelSecret": "Mot de Passe du Canal", + "channelSecretHint": "Mot de passe partagé pour ce canal", + "channelSecretHelp": "Ce mot de passe doit être partagé avec tous les membres de l'équipe qui ont besoin d'accéder à ce canal", + "channelTypesInfo": "Canaux hash (#équipe) : Mot de passe généré automatiquement à partir du nom. Même nom = même canal sur tous les appareils.\n\nCanaux privés : Utilisez un mot de passe explicite. Seuls ceux qui ont le mot de passe peuvent rejoindre.", + "hashChannelInfo": "Canal hash : Le mot de passe sera automatiquement généré à partir du nom du canal. Toute personne utilisant le même nom rejoindra le même canal.", + "channelNameRequired": "Le nom du canal est requis", + "channelNameTooLong": "Le nom du canal doit contenir 31 caractères ou moins", + "channelSecretRequired": "Le mot de passe du canal est requis", + "channelSecretTooLong": "Le mot de passe du canal doit contenir 32 caractères ou moins", + "invalidAsciiCharacters": "Seuls les caractères ASCII sont autorisés", + "channelCreatedSuccessfully": "Canal créé avec succès", + "channelCreationFailed": "Échec de la création du canal : {error}", + "deleteChannel": "Supprimer le Canal", + "deleteChannelConfirmation": "Êtes-vous sûr de vouloir supprimer le canal \"{channelName}\" ? Cette action ne peut pas être annulée.", + "channelDeletedSuccessfully": "Canal supprimé avec succès", + "channelDeletionFailed": "Échec de la suppression du canal : {error}", + "createChannel": "Créer un Canal", + "wizardBack": "Retour", + "wizardSkip": "Passer", + "wizardNext": "Suivant", + "wizardGetStarted": "Commencer", + "wizardWelcomeTitle": "Bienvenue dans MeshCore SAR", + "viewWelcomeTutorial": "Voir le tutoriel de bienvenue", + "allTeamContacts": "Tous les contacts de l'équipe", + "directMessagesInfo": "Messages directs avec confirmations. Envoyé à {count} membres de l'équipe.", + "sarMarkerSentToContacts": "Marqueur SAR envoyé à {count} contacts", + "noContactsAvailable": "Aucun contact d'équipe disponible", + "reply": "Répondre", + "technicalDetails": "Détails techniques", + "messageTechnicalDetails": "Détails techniques du message", + "linkQuality": "Qualité du lien", + "delivery": "Livraison", + "status": "Statut", + "expectedAckTag": "Balise ACK attendue", + "roundTrip": "Aller-retour", + "retryAttempt": "Tentative de renvoi", + "floodFallback": "Repli inondation", + "identity": "Identité", + "messageId": "ID du message", + "sender": "Expéditeur", + "senderKey": "Clé de l'expéditeur", + "recipient": "Destinataire", + "recipientKey": "Clé du destinataire", + "voice": "Voix", + "voiceId": "ID vocal", + "envelope": "Enveloppe", + "sessionProgress": "Progression de session", + "complete": "Complet", + "rawDump": "Vidage brut", + "cannotRetryMissingRecipient": "Impossible de réessayer : informations du destinataire manquantes", + "voiceUnavailable": "Voix indisponible pour le moment", + "requestingVoice": "Demande de voix", + "device": "appareil", + "change": "Modifier", + "wizardOverviewDescription": "Cette application réunit la messagerie MeshCore, les mises à jour SAR sur le terrain, la cartographie et les outils de l'appareil au même endroit.", + "wizardOverviewFeature1": "Envoyez des messages directs, des publications de salon et des messages de canal depuis l'onglet principal Messages.", + "wizardOverviewFeature2": "Partagez des marqueurs SAR, des dessins de carte, des extraits vocaux et des images sur le réseau maillé.", + "wizardOverviewFeature3": "Connectez-vous en BLE ou en TCP, puis gérez la radio compagnon directement dans l'application.", + "wizardMessagingTitle": "Messagerie et rapports de terrain", + "wizardMessagingDescription": "Ici, les messages sont plus que du texte brut. L'application prend déjà en charge plusieurs charges opérationnelles et flux de transfert.", + "wizardMessagingFeature1": "Envoyez des messages directs, des publications de salon et du trafic de canal depuis un seul compositeur.", + "wizardMessagingFeature2": "Créez des mises à jour SAR et des modèles SAR réutilisables pour les rapports de terrain courants.", + "wizardMessagingFeature3": "Transférez des sessions vocales et des images avec la progression et les estimations de temps d'antenne dans l'interface.", + "wizardConnectDeviceTitle": "Connecter l'appareil", + "wizardConnectDeviceDescription": "Connectez votre radio MeshCore, choisissez un nom et appliquez un préréglage radio avant de continuer.", + "wizardSetupBadge": "Configuration", + "wizardOverviewBadge": "Aperçu", + "wizardConnectedToDevice": "Connecté à {deviceName}", + "wizardNoDeviceConnected": "Aucun appareil connecté pour le moment", + "wizardSkipForNow": "Ignorer pour l'instant", + "wizardDeviceNameLabel": "Nom de l'appareil", + "wizardDeviceNameHelp": "Ce nom est annoncé aux autres utilisateurs de MeshCore.", + "wizardConfigRegionLabel": "Région de configuration", + "wizardConfigRegionHelp": "Utilise la liste officielle complète des préréglages MeshCore. La valeur par défaut est EU/UK (Narrow).", + "wizardPresetNote1": "Assurez-vous que le préréglage sélectionné correspond à votre réglementation radio locale.", + "wizardPresetNote2": "La liste correspond au flux officiel des préréglages de l'outil de configuration MeshCore.", + "wizardPresetNote3": "EU/UK (Narrow) reste sélectionné par défaut pendant l'onboarding.", + "wizardSaving": "Enregistrement...", + "wizardSaveAndContinue": "Enregistrer et continuer", + "wizardEnterDeviceName": "Saisissez un nom d'appareil avant de continuer.", + "wizardDeviceSetupSaved": "{deviceName} enregistré avec {presetName}.", + "wizardNetworkTitle": "Contacts, salons et répéteurs", + "wizardNetworkDescription": "L'onglet Contacts organise le réseau que vous découvrez et les routes apprises au fil du temps.", + "wizardNetworkFeature1": "Consultez les membres de l'équipe, les répéteurs, les salons, les canaux et les annonces en attente dans une seule liste.", + "wizardNetworkFeature2": "Utilisez le smart ping, la connexion aux salons, les chemins appris et les outils de réinitialisation des routes lorsque la connectivité devient chaotique.", + "wizardNetworkFeature3": "Créez des canaux et gérez les destinations réseau sans quitter l'application.", + "wizardMapOpsTitle": "Carte, traces et géométrie partagée", + "wizardMapOpsDescription": "La carte de l'application est directement liée à la messagerie, au suivi et aux couches SAR au lieu d'être un simple visualiseur séparé.", + "wizardMapOpsFeature1": "Suivez votre position, celles de vos coéquipiers et les traces de déplacement sur la carte.", + "wizardMapOpsFeature2": "Ouvrez les dessins depuis les messages, prévisualisez-les en ligne et supprimez-les de la carte si nécessaire.", + "wizardMapOpsFeature3": "Utilisez les vues de carte des répéteurs et les couches partagées pour comprendre la portée du réseau sur le terrain.", + "wizardToolsTitle": "Outils au-delà de la messagerie", + "wizardToolsDescription": "Il y a plus ici que les quatre onglets principaux. L'application inclut aussi la configuration, le diagnostic et des workflows de capteurs optionnels.", + "wizardToolsFeature1": "Ouvrez la configuration de l'appareil pour modifier les réglages radio, la télémétrie, la puissance TX et les détails du compagnon.", + "wizardToolsFeature2": "Activez l'onglet Capteurs lorsque vous voulez des tableaux de bord surveillés et des actions d'actualisation rapide.", + "wizardToolsFeature3": "Utilisez les journaux de paquets, le balayage du spectre et les diagnostics développeur pour dépanner le réseau maillé.", + "contactInSensors": "Dans Capteurs", + "contactAddToSensors": "Ajouter aux capteurs", + "contactSetPath": "Définir le chemin", + "contactAddedToSensors": "{contactName} ajouté aux capteurs", + "contactFailedToClearRoute": "Échec de l'effacement de la route : {error}", + "contactRouteCleared": "Route effacée", + "contactRouteSet": "Route définie : {route}", + "contactFailedToSetRoute": "Échec du réglage de la route : {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "Délai ACK expiré", + "opcode": "Opcode", + "payload": "Charge utile", + "hops": "Sauts", + "hashSize": "Taille du hash", + "pathBytes": "Octets du chemin", + "selectedPath": "Chemin sélectionné", + "estimatedTx": "Émission estimée", + "senderToReceipt": "Émetteur au reçu", + "receivedCopies": "Copies reçues", + "retryCause": "Cause de la relance", + "retryMode": "Mode de relance", + "retryResult": "Résultat de la relance", + "lastRetry": "Dernière relance", + "rxPackets": "Paquets RX", + "mesh": "Mesh", + "rate": "Débit", + "window": "Fenêtre", + "posttxDelay": "Délai post-émission", + "bandpass": "Passe-bande", + "bandpassFilterVoice": "Filtre passe-bande voix", + "active": "Actif", + "addContact": "Ajouter un contact", + "all": "Tout", + "clearAllLabel": "Tout effacer", + "clearFilters": "Effacer les filtres", + "clearRoute": "Effacer la route", + "clearMessages": "Effacer les messages", + "clearScale": "Effacer l'échelle", + "clearDiscoveries": "Effacer les découvertes", + "clearOnlineTraceDatabase": "Effacer la base de traces", + "clearAllChannels": "Effacer tous les canaux", + "clearAllContacts": "Effacer tous les contacts", + "clearChannels": "Effacer les canaux", + "clearContacts": "Effacer les contacts", + "clearPathOnMaxRetry": "Effacer le chemin au max de relances", + "create": "Créer", + "custom": "Personnalisé", + "defaultValue": "Par défaut", + "duplicate": "Dupliquer", + "editName": "Modifier le nom", + "open": "Ouvrir", + "paste": "Coller", + "preview": "Aperçu", + "remove": "Supprimer", + "rename": "Renommer", + "resolveAll": "Résoudre tout", + "send": "Envoyer", + "sendAnyway": "Envoyer quand même", + "share": "Partager", + "trace": "Trace", + "discovery": "Découverte", + "discoverRepeaters": "Découvrir les répéteurs", + "discoverSensors": "Découvrir les capteurs", + "repeaterDiscoverySent": "Découverte de répéteurs envoyée", + "sensorDiscoverySent": "Découverte de capteurs envoyée", + "clearedPendingDiscoveries": "Découvertes en attente effacées.", + "autoDiscovery": "Découverte automatique", + "enableAutomaticAdding": "Activer l'ajout automatique", + "autoaddRepeaters": "Ajout auto des répéteurs", + "autoaddRoomServers": "Ajout auto des serveurs de salle", + "autoaddSensors": "Ajout auto des capteurs", + "autoaddUsers": "Ajout auto des utilisateurs", + "overwriteOldestWhenFull": "Écraser les plus anciens si plein", + "storage": "Stockage", + "dangerZone": "Zone de danger", + "profiles": "Profils", + "favourites": "Favoris", + "sensors": "Capteurs", + "others": "Autres", + "gpsModule": "Module GPS", + "liveTraffic": "Trafic en direct", + "repeatersMap": "Carte des répéteurs", + "blePacketLogs": "Journaux de paquets BLE", + "onlineTraceDatabase": "Base de données de traces", + "routePathByteSize": "Taille en octets du chemin", + "messageNotifications": "Notifications de messages", + "sarAlerts": "Alertes SAR", + "discoveryNotifications": "Notifications de découverte", + "updateNotifications": "Notifications de mise à jour", + "muteWhileAppIsOpen": "Muet quand l'appli est ouverte", + "disableContacts": "Désactiver les contacts", + "enableSensorsTab": "Activer l'onglet Capteurs", + "enableProfiles": "Activer les profils", + "nearestRepeaterFallback": "Répéteur le plus proche en repli", + "deleteAllStoredMessageHistory": "Supprimer tout l'historique", + "messageFontSize": "Taille de police des messages", + "rotateMapWithHeading": "Tourner la carte avec le cap", + "showMapDebugInfo": "Afficher les infos de débogage", + "openMapInFullscreen": "Ouvrir la carte en plein écran", + "showSarMarkersLabel": "Afficher les marqueurs SAR", + "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", + "voiceLimiter": "Limiteur vocal", + "micAutoGain": "Gain auto du micro", + "echoCancellation": "Annulation d'écho", + "noiseSuppression": "Suppression du bruit", + "trimSilenceInVoiceMessages": "Couper le silence dans les messages vocaux", + "compressor": "Compresseur", + "limiter": "Limiteur", + "autoGain": "Gain automatique", + "echoCancel": "Écho", + "noiseSuppress": "Bruit", + "silenceTrim": "Silence", + "maxImageSize": "Taille d'image maximale", + "imageCompression": "Compression d'image", + "grayscale": "Niveaux de gris", + "ultraMode": "Mode ultra", + "fastPrivateGpsUpdates": "Mises à jour GPS privées rapides", + "movementThreshold": "Seuil de mouvement", + "fastGpsMovementThreshold": "Seuil de mouvement GPS rapide", + "fastGpsActiveuseInterval": "Intervalle d'utilisation active GPS rapide", + "activeuseUpdateInterval": "Intervalle de mise à jour en utilisation active", + "repeatNearbyTraffic": "Répéter le trafic proche", + "relayThroughRepeatersAcrossTheMesh": "Relayer via les répéteurs du réseau", + "nearbyOnlyWithoutRepeaterFlooding": "Proximité seulement, sans inondation", + "multihop": "Multi-saut", + "createProfile": "Créer un profil", + "renameProfile": "Renommer le profil", + "newProfile": "Nouveau profil", + "manageProfiles": "Gérer les profils", + "enableProfilesToStartManagingThem": "Activez les profils pour commencer à les gérer.", + "openMessage": "Ouvrir le message", + "jumpToTheRelatedSarMessage": "Aller au message SAR associé", + "removeSarMarker": "Supprimer le marqueur SAR", + "pleaseSelectADestinationToSendSarMarker": "Sélectionnez une destination pour envoyer le marqueur", + "sarMarkerBroadcastToPublicChannel": "Marqueur SAR diffusé sur le canal public", + "sarMarkerSentToRoom": "Marqueur SAR envoyé à la salle", + "loadFromGallery": "Charger depuis la galerie", + "replaceImage": "Remplacer l'image", + "selectFromGallery": "Sélectionner depuis la galerie", + "team": "Équipe", + "found": "Trouvé", + "staging": "Zone de regroupement", + "object": "Objet", + "quiet": "Calme", + "busy": "Occupé", + "searchMessages": "Rechercher des messages", + "sendImageFromGallery": "Envoyer une image de la galerie", + "takePhoto": "Prendre une photo", + "allMessages": "Tous les messages", + "sendToPublicChannel": "Envoyer au canal public ?", + "selectMarkerTypeAndDestination": "Sélectionnez le type de marqueur et la destination", + "noDestinationsAvailableLabel": "Aucune destination disponible", + "image": "Image", + "format": "Format", + "dimensions": "Dimensions", + "segments": "Segments", + "transfers": "Transferts", + "downloadedBy": "Téléchargé par", + "saveDiscoverySettings": "Enregistrer les paramètres de découverte", + "savePublicInfo": "Enregistrer les infos publiques", + "saveRadioSettings": "Enregistrer les paramètres radio", + "savePath": "Enregistrer le chemin", + "wipeDeviceData": "Effacer les données de l'appareil", + "wipeDevice": "Effacer l'appareil", + "destructiveDeviceActions": "Actions destructives de l'appareil.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Choisissez un préréglage ou ajustez les paramètres radio.", + "used": "Utilisé", + "total": "Total", + "renameValue": "Renommer la valeur", + "customizeFields": "Personnaliser les champs", + "livePreview": "Aperçu en direct", + "refreshSchedule": "Planification d'actualisation", + "noResponse": "Pas de réponse", + "refreshing": "Actualisation", + "unavailable": "Indisponible", + "pickARelayOrNodeToWatchInSensors": "Choisissez un relais ou nœud à surveiller.", + "publicKeyLabel": "Clé publique", + "alreadyInContacts": "Déjà dans les contacts", + "connectToADeviceBeforeAddingContacts": "Connectez-vous à un appareil avant d'ajouter des contacts", + "fromContacts": "Des contacts", + "onlineOnly": "En ligne uniquement", + "inBoth": "Dans les deux", + "source": "Source", + "allMessagesCleared": "Tous les messages effacés", + "onlineTraceDatabaseCleared": "Base de données de traces effacée", + "packetLogsCleared": "Journaux de paquets effacés", + "hexDataCopiedToClipboard": "Données hex copiées dans le presse-papiers", + "developerModeEnabled": "Mode développeur activé", + "developerModeDisabled": "Mode développeur désactivé", + "clipboardIsEmpty": "Le presse-papiers est vide", + "contactImported": "Contact importé", + "contactLinkCopiedToClipboard": "Lien du contact copié dans le presse-papiers", + "failedToExportContact": "Échec de l'exportation du contact", + "noLogsToExport": "Aucun journal à exporter", + "exportAsCsv": "Exporter en CSV", + "exportAsText": "Exporter en texte", + "receivedRfc3339": "Reçu (RFC3339)", + "buildTime": "Heure de build", + "downloadUrlNotAvailable": "URL de téléchargement non disponible", + "cannotOpenDownloadUrl": "Impossible d'ouvrir l'URL de téléchargement", + "updateCheckIsOnlyAvailableOnAndroid": "La vérification des mises à jour n'est disponible que sur Android", + "youAreRunningTheLatestVersion": "Vous utilisez la dernière version", + "updateAvailableButDownloadUrlNotFound": "Mise à jour disponible mais URL de téléchargement introuvable", + "startTictactoe": "Démarrer Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe indisponible", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: adversaire inconnu", + "tictactoeWaitingForStart": "Tic-Tac-Toe: en attente du début", + "acceptsShareLinks": "Accepte les liens partagés", + "supportsRawHex": "Supporte le hex brut", + "clipboardfriendly": "Compatible avec le presse-papiers", + "captured": "Capturé", + "size": "Taille", + "noCustomChannelsToClear": "Aucun canal personnalisé à effacer.", + "noDeviceContactsToClear": "Aucun contact de l'appareil à effacer.", + "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", + "simpleMode": "Mode simple", + "simpleModeDescription": "Uniquement le chat et la carte avec les contacts favoris", + "simpleModeRequiresLockedDestination": "Activez d'abord « Verrouiller la destination » avec un canal pour utiliser le mode simple", + "noFavouriteContacts": "Aucun contact favori", + "selectDestinationFirst": "Sélectionnez d'abord un canal, un contact ou un salon", + "recipientNoLongerAvailable": "Le destinataire n'est plus disponible", + "failedToSendMessage": "Échec de l'envoi : {error}", + "tictactoeDirectOnly": "Le morpion ne fonctionne qu'en messages directs. Choisissez d'abord un contact.", + "deviceKeyUnavailable": "La clé de l'appareil n'est pas disponible", + "imageCompressionFailed": "Échec de la compression de l'image", + "imageFragmentationFailed": "Échec de la fragmentation de l'image", + "failedToAnnounceImage": "Échec de l'annonce de l'image", + "noRecipientSelected": "Aucun destinataire sélectionné", + "imageSendFailed": "Échec de l'envoi de l'image", + "microphonePermissionRequiredForVoice": "L'autorisation du microphone est requise pour la voix", + "recordingDiscarded": "Enregistrement abandonné", + "publicChannelMediaWarning": "Vous êtes sur le point d'envoyer {mediaType} sur le canal public. Ce n'est pas conseillé, car tout le monde sur le réseau mesh peut le recevoir. Choisissez un canal privé ou étiqueté, sauf si c'est bien votre intention.", + "selectPrivateChannelToShareLocation": "Sélectionnez un canal privé pour partager votre position.", + "stopSharingMyLocation": "Arrêter de partager ma position", + "shareMyLocation": "Partager ma position", + "stopRecording": "Arrêter l'enregistrement", + "recordVoice": "Enregistrer un message vocal", + "moreActions": "Plus d'actions", + "noMessagesInFilter": "Aucun message dans ce filtre", + "noMatchesInFilter": "Aucun résultat dans ce filtre", + "syncFailed": "Échec de la synchronisation : {error}", + "invalidLink": "Lien invalide", + "cannotOpenLink": "Impossible d'ouvrir le lien", + "failedToOpenLink": "Échec de l'ouverture du lien", + "failedToResendMessage": "Échec du renvoi du message", + "retryFailed": "Échec de la nouvelle tentative : {error}", + "deleteDrawingAndMessage": "Supprimer le dessin et le message", + "lastEchoRelay": "Dernier écho : relais", + "lastEchoPath": "Dernier écho : chemin", + "lastEchoBytesReport": "Dernier écho : rapport d'octets", + "noGpsCoordinatesAvailable": "Aucune coordonnée GPS disponible", + "notASarMarker": "Ce n'est pas un marqueur SAR", + "failedToSaveTemplate": "Échec de l'enregistrement du modèle : {error}", + "drawingNotFound": "Dessin introuvable", + "unknownDrawingType": "Type de dessin inconnu", + "customMapMarker": "Marqueur de carte personnalisé", + "customMapPointLabel": "Point : {point}", + "mapIdLabel": "ID de carte : {id}", + "cannotFetchVoice": "Impossible de récupérer la voix", + "senderContactUnknown": "Le contact de l'expéditeur est inconnu. Synchronisez d'abord les contacts.", + "senderRouteUnknown": "La route de l'expéditeur est inconnue. Synchronisez d'abord les contacts/le chemin.", + "messageTooFar": "Le message est trop loin ({hops} sauts, max. {maxHops}).", + "senderRouteNoPathResponse": "La route de l'expéditeur n'a pas répondu à la vérification du chemin. Synchronisez les contacts/le chemin et réessayez.", + "senderRouteNoRawResponse": "La route de l'expéditeur n'a pas répondu sur le chemin de transport brut.", + "voiceFetchOverHops": "La récupération de la voix sur {hops} sauts peut prendre un moment.", + "voiceReceiveCanceled": "Réception vocale annulée", + "fetchingMissingVoiceFragments": "Récupération des fragments vocaux manquants", + "receivingVoice": "Réception de la voix", + "cannotFetchImage": "Impossible de récupérer l'image", + "imageFetchOverHops": "La récupération de l'image sur {hops} sauts peut prendre un moment.", + "imageAlreadyBeingReceived": "L'image est déjà en cours de réception", + "loadImage": "Charger l'image", + "imageFetchFailedToSendRequest": "Échec de l'envoi de la demande d'image", + "imageUnavailable": "Image indisponible pour le moment", + "imageFetchTimedOut": "Délai de récupération de l'image dépassé", + "imageReceiveCanceled": "Réception de l'image annulée", + "closeImagePreview": "Fermer l'aperçu de l'image", + "recordingReleaseToSend": "Enregistrement... relâchez pour envoyer", + "sendingVoice": "Envoi du message vocal...", + "sendLongPressToRecordVoice": "Envoyer (appui long pour enregistrer la voix)", + "discardRecording": "Abandonner l'enregistrement", + "hopCount": "{count, plural, one{{count} saut} other{{count} sauts}}", + "channelsSectionDescription": "Canaux de diffusion pour le trafic mesh à proximité", + "roomsSectionDescription": "Espaces partagés pour la coordination d'équipe", + "contactsSectionDescription": "Personnes et appareils joignables directement", + "noRecentChatters": "Aucun participant récent", + "public": "Public", + "failedToSendTicTacToeMove": "Échec de l'envoi du coup de morpion", + "youWon": "Vous avez gagné", + "opponentWon": "L'adversaire a gagné", + "gameDraw": "Match nul", + "yourTurn": "À vous de jouer", + "opponentTurn": "Au tour de l'adversaire", + "pleaseEnterValidCoordinates": "Veuillez saisir des coordonnées valides", + "locationNotAvailable": "Position non disponible", + "customCaveMapPoint": "Point de carte de grotte personnalisé", + "mapPoint": "Point de carte", + "fetchingMissingImageFragments": "Récupération des fragments manquants…", + "loadingImage": "Chargement…", + "receivingImage": "Réception…", + "tapToLoad": "Touchez pour charger", + "connectDevice": "Connecter l'appareil", + "chooseTransportSubtitle": "Choisissez le transport Bluetooth, WiFi ou Série", + "ble": "BLE", + "network": "Réseau", + "serial": "Série", + "scan": "Rechercher", + "pressScanToSearchForDevices": "Appuyez sur Rechercher pour trouver les appareils à proximité", + "failedToConnectToDevice": "Échec de la connexion à {name}", + "unknownDevice": "Appareil inconnu", + "showingCachedResultsTapRefresh": "Affichage des résultats en cache. Touchez Actualiser pour relancer la recherche.", + "scanningLocalNetworkOnPort": "Recherche d'appareils MeshCore WiFi sur le réseau local, port {port}", + "cancelScanAndAddServer": "Annuler la recherche et ajouter un serveur", + "addServer": "Ajouter un serveur", + "scanningProgressIps": "Recherche... {scanned}/{total} IP", + "cancelAndEnterManually": "Annuler et saisir manuellement", + "noRecentOrDiscoveredServers": "Aucun serveur récent ou découvert pour le moment", + "recentlyUsed": "Utilisés récemment", + "connecting": "Connexion...", + "discoveredOnThisNetwork": "Découverts sur ce réseau", + "serverNoLongerAvailable": "Le serveur {host}:{port} n'est plus disponible. Veuillez relancer la recherche pour trouver des serveurs actifs.", + "failedToConnectToHost": "Échec de la connexion à {host}:{port}", + "enterValidIpAddress": "Saisissez une adresse IP valide", + "enterValidTcpPort": "Saisissez un port TCP valide", + "ipAddress": "Adresse IP", + "tcpPort": "Port TCP", + "customServerPort": "Port de serveur personnalisé", + "chat": "Chat", + "restoringPreviousLink": "Restauration de la liaison précédente", + "noDeviceConnected": "Aucun appareil connecté", + "sendAdvert": "Envoyer mon contact", + "advertMode": "Envoyer mon contact", + "advertModeSubtitle": "Choisissez jusqu'où vos informations de contact doivent être diffusées.", + "floodAdvertSent": "Contact envoyé à travers le réseau mesh", + "directAdvertSent": "Contact envoyé aux appareils à proximité" +} diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb new file mode 100644 index 0000000..d5497f9 --- /dev/null +++ b/lib/l10n/app_hr.arb @@ -0,0 +1,1032 @@ +{ + "@@locale": "hr", + "appTitle": "MeshCore SAR", + "messages": "Poruke", + "contacts": "Kontakti", + "map": "Karta", + "settings": "Postavke", + "connect": "Poveži", + "disconnect": "Prekini", + "noDevicesFound": "Nisu pronađeni uređaji", + "scanAgain": "Skeniraj ponovno", + "deviceNotConnected": "Uređaj nije povezan", + "locationPermissionDenied": "Dopuštenje za lokaciju odbijeno", + "locationPermissionPermanentlyDenied": "Dopuštenje za lokaciju trajno odbijeno. Molimo omogućite u Postavkama.", + "locationPermissionRequired": "Dopuštenje za lokaciju potrebno je za GPS praćenje i koordinaciju tima. Možete ga omogućiti kasnije u Postavkama.", + "locationServicesDisabled": "Usluge lokacije su onemogućene. Molimo omogućite ih u Postavkama.", + "failedToGetGpsLocation": "Neuspjelo dobivanje GPS lokacije", + "failedToAdvertise": "Neuspjela objava: {error}", + "cancelReconnection": "Otkaži ponovno povezivanje", + "general": "Općenito", + "theme": "Tema", + "chooseTheme": "Odaberite temu", + "light": "Svijetla", + "dark": "Tamna", + "blueLightTheme": "Plava svijetla tema", + "blueDarkTheme": "Plava tamna tema", + "sarRed": "SAR crvena", + "alertEmergencyMode": "Način upozorenja/hitna situacija", + "sarGreen": "SAR zelena", + "safeAllClearMode": "Način sigurno/sve jasno", + "autoSystem": "Automatski (Sustav)", + "followSystemTheme": "Slijedi temu sustava", + "showRxTxIndicators": "Prikaži RX/TX indikatore", + "disableMap": "Onemogući kartu", + "language": "Jezik", + "chooseLanguage": "Odaberite jezik", + "save": "Spremi", + "cancel": "Otkaži", + "close": "Zatvori", + "about": "O aplikaciji", + "appVersion": "Verzija 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:", + "technologiesList": "• Flutter za višeplatformski razvoj\n• BLE (Bluetooth Low Energy) za mesh mrežu\n• OpenStreetMap za kartografiju\n• Provider za upravljanje stanjem\n• SharedPreferences za lokalnu pohranu", + "moreInfo": "Više informacija", + "packageName": "Ime paketa", + "sampleData": "Primjer podataka", + "sampleDataDescription": "Učitajte ili očistite primjere kontakata, poruka kanala i SAR markera za testiranje", + "loadSampleData": "Učitaj primjer", + "clearAllData": "Očisti sve podatke", + "clearAllDataConfirmTitle": "Očisti sve podatke", + "clearAllDataConfirmMessage": "Ovo će očistiti sve kontakte i SAR markere. Jeste li sigurni?", + "clear": "Očisti", + "loadedSampleData": "Učitano {teamCount} članova tima, {channelCount} kanala, {sarCount} SAR markera, {messageCount} poruka", + "failedToLoadSampleData": "Neuspjelo učitavanje primjera podataka: {error}", + "allDataCleared": "Svi podaci očišćeni", + "failedToStartBackgroundTracking": "Neuspjelo pokretanje praćenja u pozadini. Provjerite dopuštenja i BLE vezu.", + "locationBroadcast": "Emitiranje lokacije: {latitude}, {longitude}", + "defaultPinInfo": "Zadani PIN za uređaje bez zaslona je 123456. Problemi s uparivanjem? Zaboravite Bluetooth uređaj u postavkama sustava.", + "noMessagesYet": "Još nema poruka", + "pullDownToSync": "Povucite prema dolje za sinkronizaciju", + "deleteContact": "Izbriši kontakt", + "delete": "Izbriši", + "viewOnMap": "Prikaži na karti", + "refresh": "Osvježi", + "resetPath": "Resetiraj put (preusmjeri)", + "copiedToClipboard": "{label} kopirano u međuspremnik", + "pleaseEnterPassword": "Molimo unesite lozinku", + "failedToSyncContacts": "Neuspjela sinkronizacija kontakata: {error}", + "loggedInSuccessfully": "Uspješno prijavljen! Čekanje na poruke sobe...", + "loginFailed": "Prijava neuspjela - netočna lozinka", + "loggingIn": "Prijavljivanje u {roomName}...", + "failedToSendLogin": "Neuspjelo slanje prijave: {error}", + "lowLocationAccuracy": "Niska točnost lokacije", + "continue_": "Nastavi", + "sendSarMarker": "Pošalji SAR marker", + "deleteDrawing": "Izbriši crtež", + "drawLine": "Nacrtaj liniju", + "drawLineDesc": "Nacrtaj slobodnu liniju na karti", + "drawRectangle": "Nacrtaj pravokutnik", + "drawRectangleDesc": "Nacrtaj pravokutno područje na karti", + "measureDistance": "Izmjeri udaljenost", + "measureDistanceDesc": "Dugi pritisak na dvije točke za mjerenje", + "clearMeasurement": "Očisti mjerenje", + "distanceLabel": "Udaljenost: {distance}", + "longPressForSecondPoint": "Dugi pritisak za drugu točku", + "longPressToStartMeasurement": "Dugi pritisak za prvu točku", + "longPressToStartNewMeasurement": "Dugi pritisak za novo mjerenje", + "shareDrawings": "Podijeli crteže", + "clearAllDrawings": "Očisti sve crteže", + "completeLine": "Završi liniju", + "broadcastDrawingsToTeam": "Objavi {count} crtež{plural} timu", + "removeAllDrawings": "Ukloni svih {count} crtež{plural}", + "deleteAllDrawingsConfirm": "Izbrisati sve {count} crtež{plural} s karte?", + "drawing": "Crtanje", + "shareDrawingsCount": "Podijeli {count} crtež{plural}", + "showReceivedDrawings": "Prikaži primljene crteže", + "showingAllDrawings": "Prikazujem sve crteže", + "showingOnlyYourDrawings": "Prikazujem samo vaše crteže", + "showSarMarkers": "Prikaži SAR oznake", + "showingSarMarkers": "Prikazujem SAR oznake", + "hidingSarMarkers": "Skrivam SAR oznake", + "clearAll": "Očisti sve", + "publicChannel": "Javni kanal", + "broadcastToAll": "Emitiraj svim obližnjim čvorovima (privremeno)", + "storedPermanently": "Trajno pohranjeno u sobi", + "notConnectedToDevice": "Nije povezano s uređajem", + "typeYourMessage": "Upišite svoju poruku...", + "quickLocationMarker": "Brzi označitelj lokacije", + "markerType": "Vrsta markera", + "sendTo": "Pošalji na", + "noDestinationsAvailable": "Nema dostupnih odredišta.", + "selectDestination": "Odaberite odredište...", + "ephemeralBroadcastInfo": "Privremeno: Samo emitiranje. Nije pohranjeno - čvorovi moraju biti online.", + "persistentRoomInfo": "Trajno: Nepromjenjivo pohranjeno u sobi. Automatski sinkronizirano i očuvano offline.", + "location": "Lokacija", + "fromMap": "S karte", + "gettingLocation": "Dohvaćanje lokacije...", + "locationError": "Greška lokacije", + "retry": "Pokušaj ponovno", + "refreshLocation": "Osvježi lokaciju", + "accuracyMeters": "Točnost: ±{accuracy}m", + "notesOptional": "Napomene (opcionalno)", + "addAdditionalInformation": "Dodajte dodatne informacije...", + "lowAccuracyWarning": "Točnost lokacije je ±{accuracy}m. Ovo možda nije dovoljno precizno za SAR operacije.\n\nNastaviti svejedno?", + "loginToRoom": "Prijava u sobu", + "enterPasswordInfo": "Unesite lozinku za pristup ovoj sobi. Lozinka će biti spremljena za buduću upotrebu.", + "password": "Lozinka", + "enterRoomPassword": "Unesite lozinku sobe", + "loggingInDots": "Prijavljivanje...", + "login": "Prijava", + "failedToAddRoom": "Neuspjelo dodavanje sobe na uređaj: {error}\n\nSoba možda još nije oglašena.\nPokušajte pričekati da soba emitira.", + "direct": "Izravno", + "flood": "Preplavljanje", + "loggedIn": "Prijavljen", + "noGpsData": "Nema GPS podataka", + "distance": "Udaljenost", + "directPingTimeout": "Istek izravnog pinga - ponovni pokušaj {name} s preplavljanjem...", + "pingFailed": "Ping neuspješan prema {name} - nije primljen odgovor", + "deleteContactConfirmation": "Jeste li sigurni da želite izbrisati \"{name}\"?\n\nOvo će ukloniti kontakt iz aplikacije i pratećeg radio uređaja.", + "failedToRemoveContact": "Neuspjelo uklanjanje kontakta: {error}", + "type": "Vrsta", + "publicKey": "Javni ključ", + "lastSeen": "Zadnje viđen", + "yes": "Da", + "no": "Ne", + "permissions": "Dopuštenja", + "telemetry": "Telemetrija", + "voltage": "Napon", + "battery": "Baterija", + "temperature": "Temperatura", + "humidity": "Vlažnost", + "pressure": "Tlak", + "gpsTelemetry": "GPS (Telemetrija)", + "reLoginToRoom": "Ponovna prijava u sobu", + "heading": "Smjer", + "elevation": "Nadmorska visina", + "accuracy": "Točnost", + "bearing": "Azimut", + "direction": "Smjer", + "filterMarkers": "Filtriraj markere", + "filterMarkersTooltip": "Filtriraj markere", + "contactsFilter": "Kontakti", + "repeatersFilter": "Repetitori", + "sarMarkers": "SAR markeri", + "foundPerson": "Pronađena osoba", + "fire": "Požar", + "stagingArea": "Zbirno mjesto", + "showAll": "Prikaži sve", + "locationUnavailable": "Lokacija nije dostupna", + "ahead": "naprijed", + "degreesRight": "{degrees}° desno", + "degreesLeft": "{degrees}° lijevo", + "latLonFormat": "Šir: {latitude} Duž: {longitude}", + "noContactsYet": "Još nema kontakata", + "connectToDeviceToLoadContacts": "Povežite se s uređajem da učitate kontakte", + "teamMembers": "Članovi tima", + "repeaters": "Repetitori", + "rooms": "Sobe", + "channels": "Kanali", + "selectMapLayer": "Odaberite sloj karte", + "openStreetMap": "OpenStreetMap", + "openTopoMap": "OpenTopoMap", + "esriSatellite": "ESRI satelit", + "googleHybrid": "Google hibridna karta", + "googleRoadmap": "Google cestovna karta", + "googleTerrain": "Google teren", + "dragToPosition": "Povuci na poziciju", + "createSarMarker": "Kreiraj SAR marker", + "compass": "Kompas", + "navigationAndContacts": "Navigacija i kontakti", + "sarAlert": "SAR UZBUNA", + "justNow": "Upravo sada", + "minutesAgo": "prije {minutes}m", + "hoursAgo": "prije {hours}h", + "daysAgo": "prije {days}d", + "secondsAgo": "prije {seconds}s", + "sending": "Slanje...", + "sent": "Poslano", + "delivered": "Dostavljeno", + "deliveredWithTime": "Dostavljeno ({time}ms)", + "failed": "Neuspjelo", + "broadcast": "Emitirano", + "deliveredToContacts": "Dostavljeno na {delivered}/{total} kontakata", + "allDelivered": "Sve dostavljeno", + "recipientDetails": "Detalji primatelja", + "pending": "Na čekanju", + "textCopiedToClipboard": "Tekst kopiran u međuspremnik", + "cannotReplySenderMissing": "Ne mogu odgovoriti: informacije o pošiljatelju nedostaju", + "cannotReplyContactNotFound": "Ne mogu odgovoriti: kontakt nije pronađen", + "copyText": "Kopiraj tekst", + "saveAsTemplate": "Spremi kao predložak", + "templateSaved": "Predložak uspješno spremljen", + "templateAlreadyExists": "Predložak s ovim emojijem već postoji", + "deleteMessage": "Izbriši poruku", + "deleteMessageConfirmation": "Jeste li sigurni da želite izbrisati ovu poruku?", + "shareLocation": "Podijeli lokaciju", + "shareLocationText": "{markerInfo}\n\nKoordinate: {lat}, {lon}\n\nGoogle Maps: {url}", + "sarLocationShare": "SAR Lokacija", + "sarMarkerFoundPerson": "Pronađena osoba", + "sarMarkerFire": "Lokacija požara", + "sarMarkerStagingArea": "Zbirno mjesto", + "sarMarkerObject": "Pronađen objekt", + "from": "Od", + "coordinates": "Koordinate", + "tapToViewOnMap": "Dodirnite za prikaz na karti", + "radioSettings": "Postavke radija", + "frequencyMHz": "Frekvencija (MHz)", + "bandwidth": "Širina pojasa", + "spreadingFactor": "Faktor širenja", + "codingRate": "Omjer kodiranja", + "txPowerDbm": "TX snaga (dBm)", + "maxPowerDbm": "Maks: {power} dBm", + "you": "Ti", + "exportFailed": "Izvoz nije uspio: {error}", + "importFailed": "Uvoz nije uspio: {error}", + "unknown": "Nepoznato", + "onlineLayers": "Mrežni slojevi", + "locationTrail": "Putanja lokacije", + "showTrailOnMap": "Prikaži putanju na karti", + "trailVisible": "Putanja je vidljiva na karti", + "trailHiddenRecording": "Putanja je skrivena (još se snima)", + "duration": "Trajanje", + "points": "Točke", + "clearTrail": "Obriši putanju", + "clearTrailQuestion": "Obrisati putanju?", + "clearTrailConfirmation": "Jeste li sigurni da želite obrisati trenutnu putanju lokacije? Ova radnja se ne može poništiti.", + "noTrailRecorded": "Još nije snimljena putanja", + "startTrackingToRecord": "Pokrenite praćenje lokacije za snimanje putanje", + "trailControls": "Upravljanje putanjom", + "model": "Model", + "version": "Verzija", + "maxContacts": "Maks. kontakata", + "maxChannels": "Maks. kanala", + "publicInfo": "Javne informacije", + "useCurrentLocation": "Koristi trenutnu lokaciju", + "noneUnknown": "Nema/Nepoznato", + "chatNode": "Čvorište za razgovor", + "repeater": "Repetitor", + "roomChannel": "Soba/Kanal", + "typeNumber": "Tip {number}", + "failedToSave": "Neuspjelo spremanje: {error}", + "failedToGetLocation": "Neuspjelo dohvaćanje lokacije: {error}", + "sarTemplates": "SAR predlošci", + "addTemplate": "Dodaj predložak", + "editTemplate": "Uredi predložak", + "deleteTemplate": "Izbriši predložak", + "templateName": "Naziv predloška", + "templateNameHint": "npr. Pronađena osoba", + "templateEmoji": "Emoji", + "emojiRequired": "Emoji je obavezan", + "nameRequired": "Ime je obavezno", + "templateDescription": "Opis (neobavezno)", + "templateDescriptionHint": "Dodajte dodatni kontekst...", + "templateColor": "Boja", + "previewFormat": "Pregled (format SAR poruke)", + "importFromClipboard": "Uvezi", + "exportToClipboard": "Izvezi", + "deleteTemplateConfirmation": "Izbrisati predložak '{name}'?", + "templateAdded": "Predložak dodan", + "templateUpdated": "Predložak ažuriran", + "templateDeleted": "Predložak izbrisan", + "templatesImported": "{count, plural, =0{Nema uvezenih predložaka} =1{Uvezen 1 predložak} other{Uvezeno {count} predložaka}}", + "templatesExported": "{count, plural, =1{Izvezen 1 predložak u međuspremnik} other{Izvezeno {count} predložaka u međuspremnik}}", + "resetToDefaults": "Vrati na zadane", + "resetToDefaultsConfirmation": "Ovo će izbrisati sve prilagođene predloške i vratiti 4 zadana predloška. Nastaviti?", + "reset": "Vrati", + "resetComplete": "Predlošci vraćeni na zadane", + "noTemplates": "Nema dostupnih predložaka", + "tapAddToCreate": "Dodirnite + za izradu prvog predloška", + "ok": "U redu", + "locationPermission": "Dozvola za lokaciju", + "checking": "Provjera...", + "locationPermissionGrantedAlways": "Odobreno (Uvijek)", + "locationPermissionGrantedWhileInUse": "Odobreno (Tijekom uporabe)", + "locationPermissionDeniedTapToRequest": "Odbijeno - Dodirnite za zahtjev", + "locationPermissionPermanentlyDeniedOpenSettings": "Trajno odbijeno - Otvori postavke", + "locationPermissionDialogContent": "Dozvola za lokaciju je trajno odbijena. Omogućite je u postavkama uređaja kako biste koristili GPS praćenje i dijeljenje lokacije.", + "openSettings": "Otvori postavke", + "locationPermissionGranted": "Dozvola za lokaciju odobrena!", + "locationPermissionRequiredForGps": "Dozvola za lokaciju je potrebna za GPS praćenje i dijeljenje lokacije.", + "locationPermissionAlreadyGranted": "Dozvola za lokaciju je već odobrena.", + "sarNavyBlue": "SAR Mornarsko Plava", + "sarNavyBlueDescription": "Profesionalni/Operativni Način", + "selectRecipient": "Odaberi primatelja", + "broadcastToAllNearby": "Emitiraj svima u blizini", + "searchRecipients": "Pretraži primatelje...", + "noContactsFound": "Nema kontakata", + "noRoomsFound": "Nema soba", + "noRecipientsAvailable": "Nema dostupnih primatelja", + "noChannelsFound": "Nije pronađen nijedan kanal", + "newMessage": "Nova poruka", + "channel": "Kanal", + "samplePoliceLead": "Voditelj Policije", + "sampleDroneOperator": "Operater Drona", + "sampleFirefighterAlpha": "Vatrogasac", + "sampleMedicCharlie": "Medičar", + "sampleCommandDelta": "Zapovjedništvo", + "sampleFireEngine": "Vatrogasno Vozilo", + "sampleAirSupport": "Zračna Podrška", + "sampleBaseCoordinator": "Koordinator Baze", + "channelEmergency": "Hitno", + "channelCoordination": "Koordinacija", + "channelUpdates": "Ažuriranja", + "sampleTeamMember": "Primjer Člana Tima", + "sampleScout": "Primjer Izviđača", + "sampleBase": "Primjer Baze", + "sampleSearcher": "Primjer Tragača", + "sampleObjectBackpack": " Pronađen ruksak - plava boja", + "sampleObjectVehicle": " Napušteno vozilo - provjeriti vlasnika", + "sampleObjectCamping": " Otkrivena oprema za kampiranje", + "sampleObjectTrailMarker": " Oznaka staze pronađena izvan puta", + "sampleMsgAllTeamsCheckIn": "Svi timovi, javite se", + "sampleMsgWeatherUpdate": "Ažuriranje vremena: Vedro nebo, temp 18°C", + "sampleMsgBaseCamp": "Bazni kamp uspostavljen na okupljalištu", + "sampleMsgTeamAlpha": "Tim se kreće prema sektoru 2", + "sampleMsgRadioCheck": "Provjera radija - sve stanice odgovorite", + "sampleMsgWaterSupply": "Opskrba vodom dostupna na kontrolnoj točki 3", + "sampleMsgTeamBravo": "Tim izvještava: sektor 1 čist", + "sampleMsgEtaRallyPoint": "ETA do točke okupljanja: 15 minuta", + "sampleMsgSupplyDrop": "Isporuka zaliha potvrđena za 14:00", + "sampleMsgDroneSurvey": "Nadzor dronom završen - bez nalaza", + "sampleMsgTeamCharlie": "Tim traži pojačanje", + "sampleMsgRadioDiscipline": "Sve jedinice: održavati radio disciplinu", + "sampleMsgUrgentMedical": "HITNO: Potrebna medicinska pomoć u sektoru 4", + "sampleMsgAdultMale": " Odrasli muškarac, pri svijesti", + "sampleMsgFireSpotted": "Uočen požar - koordinate slijede", + "sampleMsgSpreadingRapidly": " Širi se brzo!", + "sampleMsgPriorityHelicopter": "PRIORITET: Potrebna podrška helikoptera", + "sampleMsgMedicalTeamEnRoute": "Medicinski tim na putu do vaše lokacije", + "sampleMsgEvacHelicopter": "Helikopter za evakuaciju ETA 10 minuta", + "sampleMsgEmergencyResolved": "Hitnost riješena - sve čisto", + "sampleMsgEmergencyStagingArea": " Hitno okupljalište", + "sampleMsgEmergencyServices": "Hitne službe obaviještene i odgovaraju", + "sampleAlphaTeamLead": "Voditelj Tima", + "sampleBravoScout": "Izviđač", + "sampleCharlieMedic": "Medičar", + "sampleDeltaNavigator": "Navigator", + "sampleEchoSupport": "Podrška", + "sampleBaseCommand": "Zapovjedništvo Baze", + "sampleFieldCoordinator": "Terenski Koordinator", + "sampleMedicalTeam": "Medicinski Tim", + "mapDrawing": "Crtež karte", + "navigateToDrawing": "Navigiraj do crteža", + "copyCoordinates": "Kopiraj koordinate", + "hideFromMap": "Sakrij s karte", + "lineDrawing": "Linijski crtež", + "rectangleDrawing": "Pravokutni crtež", + "manualCoordinates": "Ručne koordinate", + "enterCoordinatesManually": "Ručno unesite koordinate", + "latitudeLabel": "Geografska širina", + "longitudeLabel": "Geografska dužina", + "exampleCoordinates": "Primjer: 46.0569, 14.5058", + "drawingTools": "Alati za crtanje", + "shareDrawing": "Podijeli crtež", + "shareWithAllNearbyDevices": "Podijeli sa svim obližnjim uređajima", + "shareToRoom": "Podijeli u Sobu", + "sendToPersistentStorage": "Pošalji u trajnu pohranu sobe", + "deleteDrawingConfirm": "Jeste li sigurni da želite izbrisati ovaj crtež?", + "drawingDeleted": "Crtež izbrisan", + "yourDrawingsCount": "Vaši crteži ({count})", + "shared": "Podijeljeno", + "line": "Linija", + "rectangle": "Pravokutnik", + "updateAvailable": "Dostupno ažuriranje", + "currentVersion": "Trenutna verzija", + "latestVersion": "Najnovija verzija", + "downloadUpdate": "Preuzmi ažuriranje", + "updateLater": "Kasnije", + "cadastralParcels": "Katastarske čestice", + "forestRoads": "Šumske ceste", + "wmsOverlays": "WMS prekrivanja", + "hikingTrails": "Planinske staze", + "mainRoads": "Glavne ceste", + "houseNumbers": "Kućni brojevi", + "fireHazardZones": "Požarna ugroženost", + "historicalFires": "Povijesni požari", + "firebreaks": "Protupožarni pojasi", + "krasFireZones": "Kraška požarišta", + "placeNames": "Zemljopisna imena", + "municipalityBorders": "Općinske granice", + "topographicMap": "Topografska karta 1:25000", + "recentMessages": "Nedavne poruke", + "addChannel": "Dodaj kanal", + "channelName": "Ime kanala", + "channelNameHint": "npr. Spasilačka ekipa Alfa", + "channelSecret": "Lozinka kanala", + "channelSecretHint": "Zajednička lozinka za ovaj kanal", + "channelSecretHelp": "Ova lozinka mora biti podijeljena sa svim članovima tima koji trebaju pristup ovom kanalu", + "channelTypesInfo": "Hash kanali (#tim): Lozinka automatski generirana iz imena. Isto ime = isti kanal na svim uređajima.\n\nPrivatni kanali: Koristite eksplicitnu lozinku. Samo oni s lozinkom se mogu pridružiti.", + "hashChannelInfo": "Hash kanal: Lozinka će biti automatski generirana iz imena kanala. Bilo tko tko koristi isto ime pridružit će se istom kanalu.", + "channelNameRequired": "Ime kanala je obavezno", + "channelNameTooLong": "Ime kanala mora imati najviše 31 znak", + "channelSecretRequired": "Lozinka kanala je obavezna", + "channelSecretTooLong": "Lozinka kanala mora imati najviše 32 znaka", + "invalidAsciiCharacters": "Samo ASCII znakovi su dozvoljeni", + "channelCreatedSuccessfully": "Kanal uspješno kreiran", + "channelCreationFailed": "Neuspješno kreiranje kanala: {error}", + "deleteChannel": "Izbriši kanal", + "deleteChannelConfirmation": "Jeste li sigurni da želite izbrisati kanal \"{channelName}\"? Ova radnja se ne može poništiti.", + "channelDeletedSuccessfully": "Kanal uspješno izbrisan", + "channelDeletionFailed": "Neuspješno brisanje kanala: {error}", + "createChannel": "Kreiraj kanal", + "wizardBack": "Natrag", + "wizardSkip": "Preskoči", + "wizardNext": "Dalje", + "wizardGetStarted": "Započni", + "wizardWelcomeTitle": "Dobrodošli u MeshCore SAR", + "viewWelcomeTutorial": "Pogledaj uputu dobrodošlice", + "allTeamContacts": "Svi kontakti tima", + "directMessagesInfo": "Izravne poruke s potvrdom. Poslano {count} članovima tima.", + "sarMarkerSentToContacts": "SAR oznaka poslana {count} kontaktima", + "noContactsAvailable": "Nema dostupnih kontakata tima", + "reply": "Odgovori", + "technicalDetails": "Tehnički detalji", + "messageTechnicalDetails": "Tehnički detalji poruke", + "linkQuality": "Kvaliteta veze", + "delivery": "Dostava", + "status": "Status", + "expectedAckTag": "Očekivana ACK oznaka", + "roundTrip": "Povratno putovanje", + "retryAttempt": "Pokušaj ponovnog slanja", + "floodFallback": "Flood rezerva", + "identity": "Identitet", + "messageId": "ID poruke", + "sender": "Pošiljatelj", + "senderKey": "Ključ pošiljatelja", + "recipient": "Primatelj", + "recipientKey": "Ključ primatelja", + "voice": "Glas", + "voiceId": "ID glasa", + "envelope": "Omotnica", + "sessionProgress": "Napredak sesije", + "complete": "Dovršeno", + "rawDump": "Sirovi ispis", + "cannotRetryMissingRecipient": "Nije moguće ponoviti: nedostaju informacije o primatelju", + "voiceUnavailable": "Glas trenutno nije dostupan", + "requestingVoice": "Zahtjev za glasom", + "device": "uređaj", + "change": "Promijeni", + "wizardOverviewDescription": "Ova aplikacija objedinjuje MeshCore poruke, SAR ažuriranja s terena, karte i alate za uređaj na jednom mjestu.", + "wizardOverviewFeature1": "Šaljite izravne poruke, objave u sobama i poruke kanala iz glavne kartice Poruke.", + "wizardOverviewFeature2": "Dijelite SAR oznake, crteže karte, glasovne isječke i slike preko mesh mreže.", + "wizardOverviewFeature3": "Povežite se putem BLE-a ili TCP-a, a zatim upravljajte pratećim radijem izravno iz aplikacije.", + "wizardMessagingTitle": "Poruke i terenska izvješća", + "wizardMessagingDescription": "Poruke ovdje nisu samo običan tekst. Aplikacija već podržava više operativnih sadržaja i tijekova prijenosa.", + "wizardMessagingFeature1": "Šaljite izravne poruke, objave u sobama i promet kanala iz jednog uređivača.", + "wizardMessagingFeature2": "Stvarajte SAR ažuriranja i višekratne SAR predloške za uobičajena terenska izvješća.", + "wizardMessagingFeature3": "Prenosite glasovne sesije i slike uz prikaz napretka i procjene vremena prijenosa u sučelju.", + "wizardConnectDeviceTitle": "Poveži uređaj", + "wizardConnectDeviceDescription": "Povežite svoj MeshCore radio, odaberite naziv i primijenite radijski predložak prije nastavka.", + "wizardSetupBadge": "Postavljanje", + "wizardOverviewBadge": "Pregled", + "wizardConnectedToDevice": "Povezano s uređajem {deviceName}", + "wizardNoDeviceConnected": "Još nema povezanog uređaja", + "wizardSkipForNow": "Preskoči za sada", + "wizardDeviceNameLabel": "Naziv uređaja", + "wizardDeviceNameHelp": "Taj se naziv oglašava drugim korisnicima MeshCorea.", + "wizardConfigRegionLabel": "Regija konfiguracije", + "wizardConfigRegionHelp": "Koristi puni službeni popis MeshCore predložaka. Zadana je opcija EU/UK (Narrow).", + "wizardPresetNote1": "Provjerite odgovara li odabrani predložak lokalnim radijskim propisima.", + "wizardPresetNote2": "Popis odgovara službenom izvoru predložaka alata MeshCore config.", + "wizardPresetNote3": "EU/UK (Narrow) ostaje zadano odabran tijekom uvodnog postavljanja.", + "wizardSaving": "Spremanje...", + "wizardSaveAndContinue": "Spremi i nastavi", + "wizardEnterDeviceName": "Unesite naziv uređaja prije nastavka.", + "wizardDeviceSetupSaved": "Spremljeno {deviceName} s postavkom {presetName}.", + "wizardNetworkTitle": "Kontakti, sobe i repetitori", + "wizardNetworkDescription": "Kartica Kontakti organizira mrežu koju otkrijete i rute koje s vremenom naučite.", + "wizardNetworkFeature1": "Pregledajte članove tima, repetitore, sobe, kanale i oglase na čekanju na jednom popisu.", + "wizardNetworkFeature2": "Koristite pametni ping, prijavu u sobe, naučene putanje i alate za resetiranje ruta kada povezanost postane neuredna.", + "wizardNetworkFeature3": "Stvarajte kanale i upravljajte mrežnim odredištima bez napuštanja aplikacije.", + "wizardMapOpsTitle": "Karta, tragovi i dijeljena geometrija", + "wizardMapOpsDescription": "Karta u aplikaciji izravno je povezana s porukama, praćenjem i SAR slojevima umjesto da bude zaseban preglednik.", + "wizardMapOpsFeature1": "Pratite vlastitu poziciju, lokacije suigrača i tragove kretanja na karti.", + "wizardMapOpsFeature2": "Otvorite crteže iz poruka, pregledajte ih unutar sučelja i po potrebi ih uklonite s karte.", + "wizardMapOpsFeature3": "Koristite karte repetitora i dijeljene slojeve kako biste razumjeli doseg mreže na terenu.", + "wizardToolsTitle": "Alati izvan poruka", + "wizardToolsDescription": "Ovdje postoji više od četiri glavne kartice. Aplikacija uključuje i konfiguraciju, dijagnostiku te opcionalne radne tokove senzora.", + "wizardToolsFeature1": "Otvorite konfiguraciju uređaja kako biste promijenili radijske postavke, telemetriju, TX snagu i detalje pratećeg uređaja.", + "wizardToolsFeature2": "Omogućite karticu Senzori kada želite nadzorne ploče praćenih senzora i brza osvježavanja.", + "wizardToolsFeature3": "Koristite zapisnike paketa, skeniranje spektra i razvojnu dijagnostiku pri rješavanju problema s mesh mrežom.", + "contactInSensors": "U Senzorima", + "contactAddToSensors": "Dodaj u Senzore", + "contactSetPath": "Postavi putanju", + "contactAddedToSensors": "{contactName} dodan u Senzore", + "contactFailedToClearRoute": "Brisanje rute nije uspjelo: {error}", + "contactRouteCleared": "Ruta obrisana", + "contactRouteSet": "Ruta postavljena: {route}", + "contactFailedToSetRoute": "Postavljanje rute nije uspjelo: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "ACK istek vremena", + "opcode": "Opkod", + "payload": "Sadržaj", + "hops": "Skokovi", + "hashSize": "Veličina hash-a", + "pathBytes": "Bajtovi putanje", + "selectedPath": "Odabrana putanja", + "estimatedTx": "Procijenjeno slanje", + "senderToReceipt": "Od pošiljatelja do primitka", + "receivedCopies": "Primljene kopije", + "retryCause": "Razlog ponovnog pokušaja", + "retryMode": "Način ponovnog pokušaja", + "retryResult": "Rezultat ponovnog pokušaja", + "lastRetry": "Zadnji pokušaj", + "rxPackets": "RX paketi", + "mesh": "Mesh", + "rate": "Brzina", + "window": "Prozor", + "posttxDelay": "Kašnjenje nakon slanja", + "bandpass": "Pojasni filtar", + "bandpassFilterVoice": "Pojasni filtar glasa", + "active": "Aktivno", + "addContact": "Dodaj kontakt", + "all": "Sve", + "clearAllLabel": "Očisti sve", + "clearFilters": "Očisti filtre", + "clearRoute": "Očisti putanju", + "clearMessages": "Očisti poruke", + "clearScale": "Očisti mjerilo", + "clearDiscoveries": "Očisti otkrića", + "clearOnlineTraceDatabase": "Očisti bazu mrežnih tragova", + "clearAllChannels": "Očisti sve kanale", + "clearAllContacts": "Očisti sve kontakte", + "clearChannels": "Očisti kanale", + "clearContacts": "Očisti kontakte", + "clearPathOnMaxRetry": "Očisti putanju kod maks. pokušaja", + "create": "Stvori", + "custom": "Prilagođeno", + "defaultValue": "Zadano", + "duplicate": "Dupliciraj", + "editName": "Uredi ime", + "open": "Otvori", + "paste": "Zalijepi", + "preview": "Pregled", + "remove": "Ukloni", + "rename": "Preimenuj", + "resolveAll": "Razriješi sve", + "send": "Pošalji", + "sendAnyway": "Svejedno pošalji", + "share": "Dijeli", + "trace": "Praćenje", + "discovery": "Otkrivanje", + "discoverRepeaters": "Otkrij posrednike", + "discoverSensors": "Otkrij senzore", + "repeaterDiscoverySent": "Otkrivanje posrednika poslano", + "sensorDiscoverySent": "Otkrivanje senzora poslano", + "clearedPendingDiscoveries": "Očišćena čekajuća otkrića.", + "autoDiscovery": "Automatsko otkrivanje", + "enableAutomaticAdding": "Omogući automatsko dodavanje", + "autoaddRepeaters": "Automatski dodaj posrednike", + "autoaddRoomServers": "Automatski dodaj poslužitelje soba", + "autoaddSensors": "Automatski dodaj senzore", + "autoaddUsers": "Automatski dodaj korisnike", + "overwriteOldestWhenFull": "Prepiši najstarije kad je puno", + "storage": "Pohrana", + "dangerZone": "Opasna zona", + "profiles": "Profili", + "favourites": "Favoriti", + "sensors": "Senzori", + "others": "Ostali", + "gpsModule": "GPS modul", + "liveTraffic": "Promet uživo", + "repeatersMap": "Karta posrednika", + "blePacketLogs": "BLE zapisi paketa", + "onlineTraceDatabase": "Baza mrežnih tragova", + "routePathByteSize": "Veličina putanje u bajtima", + "messageNotifications": "Obavijesti o porukama", + "sarAlerts": "SAR upozorenja", + "discoveryNotifications": "Obavijesti o otkrivanju", + "updateNotifications": "Obavijesti o ažuriranju", + "muteWhileAppIsOpen": "Utišaj dok je aplikacija otvorena", + "disableContacts": "Onemogući kontakte", + "enableSensorsTab": "Omogući karticu Senzori", + "enableProfiles": "Omogući profile", + "nearestRepeaterFallback": "Najbliži posrednik kao rezerva", + "deleteAllStoredMessageHistory": "Izbriši svu pohranjenu povijest poruka", + "messageFontSize": "Veličina fonta poruka", + "rotateMapWithHeading": "Rotiraj kartu prema smjeru", + "showMapDebugInfo": "Prikaži debug info karte", + "openMapInFullscreen": "Otvori kartu na cijelom zaslonu", + "showSarMarkersLabel": "Prikaži SAR oznake", + "hideRepeatersOnMap": "Sakrij posrednike na karti", + "setMapScale": "Postavi mjerilo karte", + "customMapScaleSaved": "Prilagođeno mjerilo karte spremljeno", + "voiceBitrate": "Bitna brzina glasa", + "voiceCompressor": "Kompresor glasa", + "voiceLimiter": "Ograničivač glasa", + "micAutoGain": "Automatsko pojačanje mikrofona", + "echoCancellation": "Poništavanje odjeka", + "noiseSuppression": "Potiskivanje šuma", + "trimSilenceInVoiceMessages": "Izreži tišinu u glasovnim porukama", + "compressor": "Kompresor", + "limiter": "Ograničivač", + "autoGain": "Automatsko pojačanje", + "echoCancel": "Odjek", + "noiseSuppress": "Šum", + "silenceTrim": "Tišina", + "maxImageSize": "Maksimalna veličina slike", + "imageCompression": "Kompresija slike", + "grayscale": "Sivine", + "ultraMode": "Ultra način", + "fastPrivateGpsUpdates": "Brza privatna GPS ažuriranja", + "movementThreshold": "Prag kretanja", + "fastGpsMovementThreshold": "Prag kretanja za brzi GPS", + "fastGpsActiveuseInterval": "Interval aktivnog korištenja brzog GPS-a", + "activeuseUpdateInterval": "Interval ažuriranja pri aktivnom korištenju", + "repeatNearbyTraffic": "Ponovi obližnji promet", + "relayThroughRepeatersAcrossTheMesh": "Proslijedi kroz posrednike kroz mrežu", + "nearbyOnlyWithoutRepeaterFlooding": "Samo obližnji, bez preplavljivanja posrednika", + "multihop": "Višeskočno", + "createProfile": "Stvori profil", + "renameProfile": "Preimenuj profil", + "newProfile": "Novi profil", + "manageProfiles": "Upravljaj profilima", + "enableProfilesToStartManagingThem": "Omogućite profile da biste ih počeli upravljati.", + "openMessage": "Otvori poruku", + "jumpToTheRelatedSarMessage": "Skoči na povezanu SAR poruku", + "removeSarMarker": "Ukloni SAR oznaku", + "pleaseSelectADestinationToSendSarMarker": "Odaberite odredište za slanje SAR oznake", + "sarMarkerBroadcastToPublicChannel": "SAR oznaka emitirana na javni kanal", + "sarMarkerSentToRoom": "SAR oznaka poslana u sobu", + "loadFromGallery": "Učitaj iz galerije", + "replaceImage": "Zamijeni sliku", + "selectFromGallery": "Odaberi iz galerije", + "team": "Tim", + "found": "Pronađeno", + "staging": "Sabirno mjesto", + "object": "Objekt", + "quiet": "Tiho", + "busy": "Zauzeto", + "searchMessages": "Pretraži poruke", + "sendImageFromGallery": "Pošalji sliku iz galerije", + "takePhoto": "Snimi fotografiju", + "allMessages": "Sve poruke", + "sendToPublicChannel": "Pošalji na javni kanal?", + "selectMarkerTypeAndDestination": "Odaberite vrstu oznake i odredište", + "noDestinationsAvailableLabel": "Nema dostupnih odredišta", + "image": "Slika", + "format": "Format", + "dimensions": "Dimenzije", + "segments": "Segmenti", + "transfers": "Prijenosi", + "downloadedBy": "Preuzeo", + "saveDiscoverySettings": "Spremi postavke otkrivanja", + "savePublicInfo": "Spremi javne podatke", + "saveRadioSettings": "Spremi radijske postavke", + "savePath": "Spremi putanju", + "wipeDeviceData": "Obriši podatke uređaja", + "wipeDevice": "Obriši uređaj", + "destructiveDeviceActions": "Destruktivne radnje na uređaju.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Odaberite predefiniranu postavku ili fino podesite radijske postavke.", + "used": "Korišteno", + "total": "Ukupno", + "renameValue": "Preimenuj vrijednost", + "customizeFields": "Prilagodi polja", + "livePreview": "Pregled uživo", + "refreshSchedule": "Raspored osvježavanja", + "noResponse": "Nema odgovora", + "refreshing": "Osvježavam", + "unavailable": "Nedostupno", + "pickARelayOrNodeToWatchInSensors": "Odaberite posrednika ili čvor za praćenje.", + "publicKeyLabel": "Javni ključ", + "alreadyInContacts": "Već u kontaktima", + "connectToADeviceBeforeAddingContacts": "Povežite se s uređajem prije dodavanja kontakata", + "fromContacts": "Iz kontakata", + "onlineOnly": "Samo povezani", + "inBoth": "U oba", + "source": "Izvor", + "allMessagesCleared": "Sve poruke očišćene", + "onlineTraceDatabaseCleared": "Baza mrežnih tragova očišćena", + "packetLogsCleared": "Zapisi paketa očišćeni", + "hexDataCopiedToClipboard": "Hex podaci kopirani u međuspremnik", + "developerModeEnabled": "Razvojni način omogućen", + "developerModeDisabled": "Razvojni način onemogućen", + "clipboardIsEmpty": "Međuspremnik je prazan", + "contactImported": "Kontakt uvezen", + "contactLinkCopiedToClipboard": "Poveznica na kontakt kopirana u međuspremnik", + "failedToExportContact": "Izvoz kontakta nije uspio", + "noLogsToExport": "Nema zapisa za izvoz", + "exportAsCsv": "Izvezi kao CSV", + "exportAsText": "Izvezi kao tekst", + "receivedRfc3339": "Primljeno (RFC3339)", + "buildTime": "Vrijeme gradnje", + "downloadUrlNotAvailable": "URL za preuzimanje nije dostupan", + "cannotOpenDownloadUrl": "Ne mogu otvoriti URL za preuzimanje", + "updateCheckIsOnlyAvailableOnAndroid": "Provjera ažuriranja dostupna je samo na Androidu", + "youAreRunningTheLatestVersion": "Koristite najnoviju verziju", + "updateAvailableButDownloadUrlNotFound": "Ažuriranje dostupno, ali URL za preuzimanje nije pronađen", + "startTictactoe": "Pokreni Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe nije dostupan", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: protivnik nepoznat", + "tictactoeWaitingForStart": "Tic-Tac-Toe: čekanje na početak", + "acceptsShareLinks": "Prihvaća dijeljene veze", + "supportsRawHex": "Podržava sirovi hex", + "clipboardfriendly": "Pogodno za međuspremnik", + "captured": "Zabilježeno", + "size": "Veličina", + "noCustomChannelsToClear": "Nema prilagođenih kanala za brisanje.", + "noDeviceContactsToClear": "Nema kontakata uređaja za brisanje.", + "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", + "simpleMode": "Jednostavni način", + "simpleModeDescription": "Samo razgovor i karta s omiljenim kontaktima", + "simpleModeRequiresLockedDestination": "Za korištenje jednostavnog načina najprije omogućite „Zaključaj odredište” s kanalom", + "noFavouriteContacts": "Nema omiljenih kontakata", + "selectDestinationFirst": "Najprije odaberite kanal, kontakt ili sobu", + "recipientNoLongerAvailable": "Primatelj više nije dostupan", + "failedToSendMessage": "Slanje nije uspjelo: {error}", + "tictactoeDirectOnly": "Križić-kružić radi samo u izravnim porukama. Najprije odaberite kontakt.", + "deviceKeyUnavailable": "Ključ uređaja nije dostupan", + "imageCompressionFailed": "Komprimiranje slike nije uspjelo", + "imageFragmentationFailed": "Fragmentiranje slike nije uspjelo", + "failedToAnnounceImage": "Najava slike nije uspjela", + "noRecipientSelected": "Primatelj nije odabran", + "imageSendFailed": "Slanje slike nije uspjelo", + "microphonePermissionRequiredForVoice": "Za glasovne poruke potrebna je dozvola za mikrofon", + "recordingDiscarded": "Snimka je odbačena", + "publicChannelMediaWarning": "Upravo ćete poslati {mediaType} na javni kanal. To se ne preporučuje jer sadržaj mogu primiti svi na mesh mreži. Odaberite privatni ili označeni kanal, osim ako je to baš ono što želite.", + "selectPrivateChannelToShareLocation": "Odaberite privatni kanal za dijeljenje lokacije.", + "stopSharingMyLocation": "Prestani dijeliti moju lokaciju", + "shareMyLocation": "Dijeli moju lokaciju", + "stopRecording": "Zaustavi snimanje", + "recordVoice": "Snimi glasovnu poruku", + "moreActions": "Više radnji", + "noMessagesInFilter": "Nema poruka u ovom filtru", + "noMatchesInFilter": "Nema rezultata u ovom filtru", + "syncFailed": "Sinkronizacija nije uspjela: {error}", + "invalidLink": "Nevažeća poveznica", + "cannotOpenLink": "Poveznicu nije moguće otvoriti", + "failedToOpenLink": "Otvaranje poveznice nije uspjelo", + "failedToResendMessage": "Ponovno slanje poruke nije uspjelo", + "retryFailed": "Ponovni pokušaj nije uspio: {error}", + "deleteDrawingAndMessage": "Izbriši crtež i poruku", + "lastEchoRelay": "Zadnji odjek – relej", + "lastEchoPath": "Zadnji odjek – putanja", + "lastEchoBytesReport": "Zadnji odjek – izvještaj o bajtovima", + "noGpsCoordinatesAvailable": "GPS koordinate nisu dostupne", + "notASarMarker": "Nije SAR marker", + "failedToSaveTemplate": "Spremanje predloška nije uspjelo: {error}", + "drawingNotFound": "Crtež nije pronađen", + "unknownDrawingType": "Nepoznata vrsta crteža", + "customMapMarker": "Prilagođeni marker karte", + "customMapPointLabel": "Točka: {point}", + "mapIdLabel": "ID karte: {id}", + "cannotFetchVoice": "Glasovnu poruku nije moguće dohvatiti", + "senderContactUnknown": "Kontakt pošiljatelja nije poznat. Najprije sinkronizirajte kontakte.", + "senderRouteUnknown": "Ruta pošiljatelja nije poznata. Najprije sinkronizirajte kontakte/putanju.", + "messageTooFar": "Poruka je predaleko ({hops} skokova, najviše {maxHops}).", + "senderRouteNoPathResponse": "Ruta pošiljatelja nije odgovorila na provjeru putanje. Sinkronizirajte kontakte/putanju i pokušajte ponovno.", + "senderRouteNoRawResponse": "Ruta pošiljatelja nije odgovorila na izravnoj prijenosnoj putanji.", + "voiceFetchOverHops": "Dohvaćanje glasa preko {hops} skokova može potrajati.", + "voiceReceiveCanceled": "Primanje glasa je otkazano", + "fetchingMissingVoiceFragments": "Dohvaćanje nedostajućih glasovnih fragmenata", + "receivingVoice": "Primanje glasa", + "cannotFetchImage": "Sliku nije moguće dohvatiti", + "imageFetchOverHops": "Dohvaćanje slike preko {hops} skokova može potrajati.", + "imageAlreadyBeingReceived": "Slika se već prima", + "loadImage": "Učitaj sliku", + "imageFetchFailedToSendRequest": "Zahtjev za dohvaćanje slike nije poslan", + "imageUnavailable": "Slika trenutačno nije dostupna", + "imageFetchTimedOut": "Dohvaćanje slike je isteklo", + "imageReceiveCanceled": "Primanje slike je otkazano", + "closeImagePreview": "Zatvori pregled slike", + "recordingReleaseToSend": "Snimanje... otpustite za slanje glasa", + "sendingVoice": "Slanje glasa...", + "sendLongPressToRecordVoice": "Pošalji (dugi pritisak za snimanje glasa)", + "discardRecording": "Odbaci snimku", + "hopCount": "{count, plural, one{{count} skok} few{{count} skoka} other{{count} skokova}}", + "channelsSectionDescription": "Kanali za emitiranje mesh prometa u blizini", + "roomsSectionDescription": "Zajednički prostori za stalnu koordinaciju tima", + "contactsSectionDescription": "Osobe i uređaji koje možete izravno dosegnuti", + "noRecentChatters": "Nema nedavnih sudionika", + "public": "Javno", + "failedToSendTicTacToeMove": "Potez križić-kružić nije poslan", + "youWon": "Pobijedili ste", + "opponentWon": "Protivnik je pobijedio", + "gameDraw": "Neriješeno", + "yourTurn": "Vi ste na potezu", + "opponentTurn": "Protivnik je na potezu", + "pleaseEnterValidCoordinates": "Unesite valjane koordinate", + "locationNotAvailable": "Lokacija nije dostupna", + "customCaveMapPoint": "Prilagođena točka karte špilje", + "mapPoint": "Točka karte", + "fetchingMissingImageFragments": "Dohvaćanje nedostajućih fragmenata…", + "loadingImage": "Učitavanje…", + "receivingImage": "Primanje…", + "tapToLoad": "Dodirnite za učitavanje", + "connectDevice": "Poveži uređaj", + "chooseTransportSubtitle": "Odaberite način povezivanja: Bluetooth, WiFi ili serijski", + "ble": "BLE", + "network": "Mreža", + "serial": "Serijski", + "scan": "Skeniraj", + "pressScanToSearchForDevices": "Pritisnite Skeniraj za traženje uređaja u blizini", + "failedToConnectToDevice": "Povezivanje s uređajem {name} nije uspjelo", + "unknownDevice": "Nepoznat uređaj", + "showingCachedResultsTapRefresh": "Prikazani su predmemorirani rezultati. Dodirnite osvježi za ponovno skeniranje.", + "scanningLocalNetworkOnPort": "Skeniranje lokalne mreže za MeshCore WiFi uređaje na portu {port}", + "cancelScanAndAddServer": "Otkaži skeniranje i dodaj poslužitelj", + "addServer": "Dodaj poslužitelj", + "scanningProgressIps": "Skeniranje... {scanned}/{total} IP adresa", + "cancelAndEnterManually": "Otkaži i unesi ručno", + "noRecentOrDiscoveredServers": "Još nema nedavnih ili otkrivenih poslužitelja", + "recentlyUsed": "Nedavno korišteno", + "connecting": "Povezivanje...", + "discoveredOnThisNetwork": "Otkriveno na ovoj mreži", + "serverNoLongerAvailable": "Poslužitelj na {host}:{port} više nije dostupan. Skenirajte ponovno da pronađete aktivne poslužitelje.", + "failedToConnectToHost": "Povezivanje s {host}:{port} nije uspjelo", + "enterValidIpAddress": "Unesite valjanu IP adresu", + "enterValidTcpPort": "Unesite valjani TCP port", + "ipAddress": "IP adresa", + "tcpPort": "TCP port", + "customServerPort": "Prilagođeni port poslužitelja", + "chat": "Razgovor", + "restoringPreviousLink": "Obnavljanje prethodne veze", + "noDeviceConnected": "Nijedan uređaj nije povezan", + "sendAdvert": "Pošalji moj kontakt", + "advertMode": "Pošalji moj kontakt", + "advertModeSubtitle": "Odaberite koliko daleko trebaju putovati vaši kontaktni podaci.", + "floodAdvertSent": "Kontakt poslan preplavljanjem kroz mrežu", + "directAdvertSent": "Kontakt poslan izravno obližnjim uređajima" +} diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb new file mode 100644 index 0000000..9779100 --- /dev/null +++ b/lib/l10n/app_it.arb @@ -0,0 +1,2002 @@ +{ + "@@locale": "it", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "Il titolo dell'applicazione" + }, + "messages": "Messaggi", + "@messages": { + "description": "Etichetta della scheda Messaggi" + }, + "contacts": "Contatti", + "@contacts": { + "description": "Etichetta della scheda Contatti" + }, + "map": "Mappa", + "@map": { + "description": "Etichetta della scheda Mappa" + }, + "settings": "Impostazioni", + "@settings": { + "description": "Titolo della schermata Impostazioni" + }, + "connect": "Connetti", + "@connect": { + "description": "Etichetta del pulsante Connetti" + }, + "disconnect": "Disconnetti", + "@disconnect": { + "description": "Etichetta del pulsante Disconnetti" + }, + "noDevicesFound": "Nessun dispositivo trovato", + "@noDevicesFound": { + "description": "Testo mostrato quando non vengono trovati dispositivi BLE" + }, + "scanAgain": "Cerca Nuovamente", + "@scanAgain": { + "description": "Pulsante per riavviare la ricerca BLE" + }, + "deviceNotConnected": "Dispositivo non connesso", + "@deviceNotConnected": { + "description": "Messaggio di errore quando il dispositivo non è connesso" + }, + "locationPermissionDenied": "Autorizzazione posizione negata", + "@locationPermissionDenied": { + "description": "Errore quando l'autorizzazione alla posizione viene negata" + }, + "locationPermissionPermanentlyDenied": "Autorizzazione posizione negata permanentemente. Abilitarla nelle Impostazioni.", + "@locationPermissionPermanentlyDenied": { + "description": "Errore quando l'autorizzazione alla posizione è negata permanentemente" + }, + "locationPermissionRequired": "L'autorizzazione alla posizione è necessaria per il tracciamento GPS e il coordinamento del team. Puoi abilitarla successivamente nelle Impostazioni.", + "@locationPermissionRequired": { + "description": "Messaggio quando è necessaria l'autorizzazione alla posizione" + }, + "locationServicesDisabled": "I servizi di localizzazione sono disabilitati. Abilitarli nelle Impostazioni.", + "@locationServicesDisabled": { + "description": "Errore quando i servizi di localizzazione sono disabilitati" + }, + "failedToGetGpsLocation": "Impossibile ottenere la posizione GPS", + "@failedToGetGpsLocation": { + "description": "Errore quando non è possibile ottenere la posizione GPS" + }, + "failedToAdvertise": "Annuncio fallito: {error}", + "@failedToAdvertise": { + "description": "Messaggio di errore per annuncio fallito", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "Annulla riconnessione", + "@cancelReconnection": { + "description": "Tooltip per il pulsante annulla riconnessione" + }, + "general": "Generale", + "@general": { + "description": "Intestazione sezione impostazioni generali" + }, + "theme": "Tema", + "@theme": { + "description": "Etichetta impostazione tema" + }, + "chooseTheme": "Scegli Tema", + "@chooseTheme": { + "description": "Titolo della finestra di selezione tema" + }, + "light": "Chiaro", + "@light": { + "description": "Opzione tema chiaro" + }, + "dark": "Scuro", + "@dark": { + "description": "Opzione tema scuro" + }, + "blueLightTheme": "Tema blu chiaro", + "@blueLightTheme": { + "description": "Descrizione per il tema blu chiaro" + }, + "blueDarkTheme": "Tema blu scuro", + "@blueDarkTheme": { + "description": "Descrizione per il tema blu scuro" + }, + "sarRed": "SAR Rosso", + "@sarRed": { + "description": "Opzione tema SAR Rosso" + }, + "alertEmergencyMode": "Modalità Allerta/Emergenza", + "@alertEmergencyMode": { + "description": "Descrizione per il tema SAR Rosso" + }, + "sarGreen": "SAR Verde", + "@sarGreen": { + "description": "Opzione tema SAR Verde" + }, + "safeAllClearMode": "Modalità Sicuro/Tutto Libero", + "@safeAllClearMode": { + "description": "Descrizione per il tema SAR Verde" + }, + "autoSystem": "Auto (Sistema)", + "@autoSystem": { + "description": "Opzione tema Auto/Sistema" + }, + "followSystemTheme": "Segui tema di sistema", + "@followSystemTheme": { + "description": "Descrizione per il tema di sistema" + }, + "showRxTxIndicators": "Mostra Indicatori RX/TX", + "@showRxTxIndicators": { + "description": "Impostazione per mostrare gli indicatori RX/TX" + }, + "disableMap": "Disabilita mappa", + "@disableMap": { + "description": "Impostazione per disabilitare la scheda mappa" + }, + "language": "Lingua", + "@language": { + "description": "Etichetta impostazione lingua" + }, + "chooseLanguage": "Scegli Lingua", + "@chooseLanguage": { + "description": "Titolo della finestra di selezione lingua" + }, + "save": "Salva", + "@save": { + "description": "Etichetta pulsante Salva" + }, + "cancel": "Annulla", + "@cancel": { + "description": "Etichetta pulsante Annulla" + }, + "close": "Chiudi", + "@close": { + "description": "Etichetta pulsante Chiudi" + }, + "about": "Informazioni", + "@about": { + "description": "Intestazione sezione Informazioni" + }, + "appVersion": "Versione App", + "@appVersion": { + "description": "Etichetta versione app" + }, + "aboutMeshCoreSar": "Informazioni su MeshCore SAR", + "@aboutMeshCoreSar": { + "description": "Titolo della finestra Informazioni" + }, + "aboutDescription": "Un'applicazione di Ricerca e Soccorso progettata per i team di emergenza. Le caratteristiche includono:\n\n• Rete mesh BLE per comunicazione dispositivo-a-dispositivo\n• Mappe offline con opzioni di livelli multipli\n• Tracciamento in tempo reale dei membri del team\n• Marcatori tattici SAR (persona trovata, incendio, area di appoggio)\n• Gestione contatti e messaggistica\n• Tracciamento GPS con direzione bussola\n• Caching dei tile della mappa per uso offline", + "@aboutDescription": { + "description": "Descrizione nella finestra Informazioni" + }, + "technologiesUsed": "Tecnologie Utilizzate:", + "@technologiesUsed": { + "description": "Titolo sezione tecnologie utilizzate" + }, + "technologiesList": "• Flutter per lo sviluppo multipiattaforma\n• BLE (Bluetooth Low Energy) per la rete mesh\n• OpenStreetMap per la mappatura\n• Provider per la gestione dello stato\n• SharedPreferences per l'archiviazione locale", + "@technologiesList": { + "description": "Elenco delle tecnologie utilizzate" + }, + "moreInfo": "Maggiori informazioni", + "@moreInfo": { + "description": "Etichetta del pulsante Maggiori informazioni" + }, + "packageName": "Nome Pacchetto", + "@packageName": { + "description": "Etichetta nome pacchetto" + }, + "sampleData": "Dati di Esempio", + "@sampleData": { + "description": "Intestazione sezione dati di esempio" + }, + "sampleDataDescription": "Carica o cancella contatti di esempio, messaggi di canale e marcatori SAR per test", + "@sampleDataDescription": { + "description": "Descrizione sezione dati di esempio" + }, + "loadSampleData": "Carica Dati di Esempio", + "@loadSampleData": { + "description": "Pulsante carica dati di esempio" + }, + "clearAllData": "Cancella Tutti i Dati", + "@clearAllData": { + "description": "Pulsante cancella tutti i dati" + }, + "clearAllDataConfirmTitle": "Cancella Tutti i Dati", + "@clearAllDataConfirmTitle": { + "description": "Titolo della finestra di conferma cancellazione dati" + }, + "clearAllDataConfirmMessage": "Questo cancellerà tutti i contatti e i marcatori SAR. Sei sicuro?", + "@clearAllDataConfirmMessage": { + "description": "Messaggio di conferma cancellazione dati" + }, + "clear": "Cancella", + "@clear": { + "description": "Etichetta pulsante Cancella" + }, + "loadedSampleData": "Caricati {teamCount} membri del team, {channelCount} canali, {sarCount} marcatori SAR, {messageCount} messaggi", + "@loadedSampleData": { + "description": "Messaggio di successo dopo il caricamento dei dati di esempio", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "Impossibile caricare dati di esempio: {error}", + "@failedToLoadSampleData": { + "description": "Messaggio di errore quando il caricamento dei dati di esempio fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "Tutti i dati cancellati", + "@allDataCleared": { + "description": "Messaggio di successo dopo la cancellazione di tutti i dati" + }, + "failedToStartBackgroundTracking": "Impossibile avviare il tracciamento in background. Verifica le autorizzazioni e la connessione BLE.", + "@failedToStartBackgroundTracking": { + "description": "Messaggio di errore quando il tracciamento in background fallisce" + }, + "locationBroadcast": "Trasmissione posizione: {latitude}, {longitude}", + "@locationBroadcast": { + "description": "Messaggio di successo per la trasmissione della posizione", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "Il PIN predefinito per i dispositivi senza schermo è 123456. Problemi di accoppiamento? Dimentica il dispositivo bluetooth nelle impostazioni di sistema.", + "@defaultPinInfo": { + "description": "Informazioni sul PIN predefinito per l'accoppiamento" + }, + "noMessagesYet": "Nessun messaggio ancora", + "@noMessagesYet": { + "description": "Messaggio di stato vuoto quando non ci sono messaggi" + }, + "pullDownToSync": "Trascina verso il basso per sincronizzare i messaggi", + "@pullDownToSync": { + "description": "Istruzione per trascinare verso il basso per aggiornare i messaggi" + }, + "deleteContact": "Elimina Contatto", + "@deleteContact": { + "description": "Etichetta azione elimina contatto" + }, + "delete": "Elimina", + "@delete": { + "description": "Etichetta pulsante Elimina" + }, + "viewOnMap": "Visualizza su Mappa", + "@viewOnMap": { + "description": "Azione per visualizzare la posizione del contatto sulla mappa" + }, + "refresh": "Aggiorna", + "@refresh": { + "description": "Etichetta pulsante Aggiorna" + }, + "resetPath": "Resetta Percorso (Ri-instrada)", + "@resetPath": { + "description": "Azione per resettare il percorso del contatto per il re-instradamento" + }, + "copiedToClipboard": "{label} copiato negli appunti", + "@copiedToClipboard": { + "description": "Messaggio di successo quando un valore viene copiato negli appunti", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "Inserisci una password", + "@pleaseEnterPassword": { + "description": "Messaggio di validazione per campo password vuoto" + }, + "failedToSyncContacts": "Impossibile sincronizzare i contatti: {error}", + "@failedToSyncContacts": { + "description": "Messaggio di errore quando la sincronizzazione dei contatti fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "Accesso effettuato con successo! In attesa dei messaggi della stanza...", + "@loggedInSuccessfully": { + "description": "Messaggio di successo dopo l'accesso alla stanza" + }, + "loginFailed": "Accesso fallito - password errata", + "@loginFailed": { + "description": "Messaggio di errore quando l'accesso alla stanza fallisce" + }, + "loggingIn": "Accesso a {roomName} in corso...", + "@loggingIn": { + "description": "Messaggio di stato durante il processo di accesso alla stanza", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "Impossibile inviare l'accesso: {error}", + "@failedToSendLogin": { + "description": "Messaggio di errore quando il comando di accesso fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "Precisione Posizione Bassa", + "@lowLocationAccuracy": { + "description": "Titolo avviso per precisione GPS bassa" + }, + "continue_": "Continua", + "@continue_": { + "description": "Etichetta pulsante Continua" + }, + "sendSarMarker": "Invia marcatore SAR", + "@sendSarMarker": { + "description": "Azione per inviare marcatore SAR" + }, + "deleteDrawing": "Elimina Disegno", + "@deleteDrawing": { + "description": "Azione per eliminare un disegno sulla mappa" + }, + "drawingTools": "Strumenti di Disegno", + "@drawingTools": { + "description": "Sezione strumenti di disegno o titolo del menu" + }, + "drawLine": "Disegna Linea", + "@drawLine": { + "description": "Modalità disegno mappa: linea" + }, + "drawLineDesc": "Disegna una linea a mano libera sulla mappa", + "@drawLineDesc": { + "description": "Descrizione per la modalità disegno linea" + }, + "drawRectangle": "Disegna Rettangolo", + "@drawRectangle": { + "description": "Modalità disegno mappa: rettangolo" + }, + "drawRectangleDesc": "Disegna un'area rettangolare sulla mappa", + "@drawRectangleDesc": { + "description": "Descrizione per la modalità disegno rettangolo" + }, + "measureDistance": "Misura Distanza", + "@measureDistance": { + "description": "Modalità disegno mappa: misura distanza" + }, + "measureDistanceDesc": "Premi a lungo su due punti per misurare", + "@measureDistanceDesc": { + "description": "Descrizione per la modalità misurazione distanza" + }, + "clearMeasurement": "Cancella Misurazione", + "@clearMeasurement": { + "description": "Tooltip per cancellare la misurazione" + }, + "distanceLabel": "Distanza: {distance}", + "@distanceLabel": { + "description": "Etichetta che mostra la distanza misurata", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "Premi a lungo per il secondo punto", + "@longPressForSecondPoint": { + "description": "Istruzione quando è impostato il primo punto di misurazione" + }, + "longPressToStartMeasurement": "Premi a lungo per impostare il primo punto", + "@longPressToStartMeasurement": { + "description": "Istruzione per avviare la misurazione" + }, + "longPressToStartNewMeasurement": "Premi a lungo per nuova misurazione", + "@longPressToStartNewMeasurement": { + "description": "Istruzione per riavviare la misurazione dopo il completamento" + }, + "shareDrawings": "Condividi Disegni", + "@shareDrawings": { + "description": "Azione per condividere disegni sulla rete" + }, + "clearAllDrawings": "Cancella Tutti i Disegni", + "@clearAllDrawings": { + "description": "Azione per cancellare tutti i disegni locali" + }, + "completeLine": "Completa Linea", + "@completeLine": { + "description": "Tooltip per completare il disegno di una linea" + }, + "broadcastDrawingsToTeam": "Trasmetti {count} disegno{plural} alla squadra", + "@broadcastDrawingsToTeam": { + "description": "Sottotitolo che mostra quanti disegni verranno trasmessi", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "Rimuovi tutti i {count} disegno{plural}", + "@removeAllDrawings": { + "description": "Sottotitolo per l'azione di rimozione di tutti i disegni", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "Eliminare tutti i {count} disegno{plural} dalla mappa?", + "@deleteAllDrawingsConfirm": { + "description": "Messaggio di conferma per eliminare tutti i disegni", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "Disegno", + "@drawing": { + "description": "Etichetta generica per disegno" + }, + "shareDrawingsCount": "Condividi {count} disegno{plural}", + "@shareDrawingsCount": { + "description": "Titolo per il dialogo di condivisione disegni", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "Mostra Disegni Ricevuti", + "@showReceivedDrawings": { + "description": "Interruttore per mostrare/nascondere i disegni ricevuti da altri membri del team" + }, + "showingAllDrawings": "Visualizzazione di tutti i disegni", + "@showingAllDrawings": { + "description": "Sottotitolo quando i disegni ricevuti sono visibili" + }, + "showingOnlyYourDrawings": "Visualizzazione solo dei tuoi disegni", + "@showingOnlyYourDrawings": { + "description": "Sottotitolo quando i disegni ricevuti sono nascosti" + }, + "showSarMarkers": "Mostra marcatori SAR", + "@showSarMarkers": { + "description": "Interruttore per mostrare/nascondere i marcatori SAR sulla mappa" + }, + "showingSarMarkers": "Visualizzazione marcatori SAR", + "@showingSarMarkers": { + "description": "Sottotitolo quando i marcatori SAR sono visibili" + }, + "hidingSarMarkers": "Nascondere marcatori SAR", + "@hidingSarMarkers": { + "description": "Sottotitolo quando i marcatori SAR sono nascosti" + }, + "clearAll": "Cancella Tutto", + "@clearAll": { + "description": "Etichetta pulsante Cancella Tutto" + }, + "publicChannel": "Canale Pubblico", + "@publicChannel": { + "description": "Opzione canale pubblico per la condivisione" + }, + "broadcastToAll": "Trasmetti a tutti i nodi vicini (effimero)", + "@broadcastToAll": { + "description": "Descrizione per la trasmissione sul canale pubblico" + }, + "storedPermanently": "Archiviato permanentemente nella stanza", + "@storedPermanently": { + "description": "Descrizione per la permanenza dell'archiviazione nella stanza" + }, + "notConnectedToDevice": "Non connesso al dispositivo", + "@notConnectedToDevice": { + "description": "Messaggio di errore quando il dispositivo non è connesso per la messaggistica diretta" + }, + "typeYourMessage": "Scrivi il tuo messaggio...", + "@typeYourMessage": { + "description": "Testo segnaposto per il campo di input del messaggio" + }, + "quickLocationMarker": "Marcatore posizione rapido", + "@quickLocationMarker": { + "description": "Sottotitolo per l'intestazione del pannello marcatore SAR" + }, + "markerType": "Tipo Marcatore", + "@markerType": { + "description": "Etichetta per la sezione di selezione tipo marcatore" + }, + "sendTo": "Invia A", + "@sendTo": { + "description": "Etichetta per la sezione di selezione destinazione" + }, + "noDestinationsAvailable": "Nessuna destinazione disponibile.", + "@noDestinationsAvailable": { + "description": "Avviso quando non esistono stanze o canali" + }, + "selectDestination": "Seleziona destinazione...", + "@selectDestination": { + "description": "Segnaposto per il menu a discesa della destinazione" + }, + "ephemeralBroadcastInfo": "Effimero: Trasmissione via etere solamente. Non archiviato - i nodi devono essere online.", + "@ephemeralBroadcastInfo": { + "description": "Informazioni sulle trasmissioni di canale effimere" + }, + "persistentRoomInfo": "Persistente: Archiviato in modo immutabile nella stanza. Sincronizzato automaticamente e conservato offline.", + "@persistentRoomInfo": { + "description": "Informazioni sull'archiviazione persistente della stanza" + }, + "location": "Posizione", + "@location": { + "description": "Etichetta per la sezione posizione" + }, + "fromMap": "Dalla Mappa", + "@fromMap": { + "description": "Badge che mostra che la posizione proviene dal tocco sulla mappa" + }, + "gettingLocation": "Ottenimento posizione...", + "@gettingLocation": { + "description": "Messaggio di caricamento durante il recupero della posizione GPS" + }, + "locationError": "Errore Posizione", + "@locationError": { + "description": "Titolo per i messaggi di errore della posizione" + }, + "retry": "Riprova", + "@retry": { + "description": "Etichetta pulsante Riprova" + }, + "refreshLocation": "Aggiorna posizione", + "@refreshLocation": { + "description": "Tooltip per il pulsante aggiorna posizione" + }, + "accuracyMeters": "Precisione: ±{accuracy}m", + "@accuracyMeters": { + "description": "Visualizzazione della precisione GPS in metri", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "Note (facoltativo)", + "@notesOptional": { + "description": "Etichetta per il campo note facoltativo" + }, + "addAdditionalInformation": "Aggiungi informazioni aggiuntive...", + "@addAdditionalInformation": { + "description": "Segnaposto per il campo note" + }, + "lowAccuracyWarning": "La precisione della posizione è ±{accuracy}m. Potrebbe non essere abbastanza accurata per le operazioni SAR.\n\nContinuare comunque?", + "@lowAccuracyWarning": { + "description": "Contenuto della finestra di avviso per precisione GPS bassa", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "Accedi alla Stanza", + "@loginToRoom": { + "description": "Titolo per la finestra di accesso alla stanza" + }, + "enterPasswordInfo": "Inserisci la password per accedere a questa stanza. La password verrà salvata per usi futuri.", + "@enterPasswordInfo": { + "description": "Informazioni sulla password della stanza" + }, + "password": "Password", + "@password": { + "description": "Etichetta campo password" + }, + "enterRoomPassword": "Inserisci password stanza", + "@enterRoomPassword": { + "description": "Suggerimento campo password" + }, + "loggingInDots": "Accesso in corso...", + "@loggingInDots": { + "description": "Testo pulsante durante l'accesso" + }, + "login": "Accedi", + "@login": { + "description": "Etichetta pulsante Accedi" + }, + "failedToAddRoom": "Impossibile aggiungere la stanza al dispositivo: {error}\n\nLa stanza potrebbe non aver ancora trasmesso.\nProva ad attendere che la stanza trasmetta.", + "@failedToAddRoom": { + "description": "Messaggio di errore quando l'aggiunta della stanza fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "Diretto", + "@direct": { + "description": "Indicatore instradamento diretto" + }, + "flood": "Flood", + "@flood": { + "description": "Indicatore instradamento flood" + }, + "loggedIn": "Connesso", + "@loggedIn": { + "description": "Badge stato connesso" + }, + "noGpsData": "Nessun dato GPS", + "@noGpsData": { + "description": "Messaggio quando i dati GPS non sono disponibili" + }, + "distance": "Distanza", + "@distance": { + "description": "Etichetta distanza" + }, + "directPingTimeout": "Timeout ping diretto - nuovo tentativo {name} con flooding...", + "@directPingTimeout": { + "description": "Avviso quando il ping diretto va in timeout", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "Ping fallito a {name} - nessuna risposta ricevuta", + "@pingFailed": { + "description": "Messaggio di errore quando il ping fallisce", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "Sei sicuro di voler eliminare \"{name}\"?\n\nQuesto rimuoverà il contatto sia dall'app che dal dispositivo radio companion.", + "@deleteContactConfirmation": { + "description": "Messaggio di conferma per l'eliminazione del contatto", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "Impossibile rimuovere il contatto: {error}", + "@failedToRemoveContact": { + "description": "Messaggio di errore quando la rimozione del contatto fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "Tipo", + "@type": { + "description": "Etichetta tipo contatto" + }, + "publicKey": "Chiave Pubblica", + "@publicKey": { + "description": "Etichetta chiave pubblica" + }, + "lastSeen": "Ultima Visita", + "@lastSeen": { + "description": "Etichetta ultima visita" + }, + "yes": "Sì", + "@yes": { + "description": "Risposta Sì" + }, + "no": "No", + "@no": { + "description": "Risposta No" + }, + "permissions": "Permessi", + "@permissions": { + "description": "Etichetta permessi" + }, + "telemetry": "Telemetria", + "@telemetry": { + "description": "Intestazione sezione telemetria" + }, + "voltage": "Tensione", + "@voltage": { + "description": "Etichetta tensione" + }, + "battery": "Batteria", + "@battery": { + "description": "Etichetta batteria" + }, + "temperature": "Temperatura", + "@temperature": { + "description": "Etichetta temperatura" + }, + "humidity": "Umidità", + "@humidity": { + "description": "Etichetta umidità" + }, + "pressure": "Pressione", + "@pressure": { + "description": "Etichetta pressione" + }, + "gpsTelemetry": "GPS (Telemetria)", + "@gpsTelemetry": { + "description": "Etichetta GPS da telemetria" + }, + "reLoginToRoom": "Riaccedi alla Stanza", + "@reLoginToRoom": { + "description": "Pulsante per riaccedere alla stanza" + }, + "heading": "Direzione", + "@heading": { + "description": "Etichetta direzione bussola" + }, + "elevation": "Elevazione", + "@elevation": { + "description": "Etichetta elevazione/altitudine" + }, + "accuracy": "Precisione", + "@accuracy": { + "description": "Etichetta precisione GPS" + }, + "bearing": "Rilevamento", + "@bearing": { + "description": "Etichetta rilevamento nella bussola" + }, + "direction": "Direzione", + "@direction": { + "description": "Etichetta direzione nella bussola" + }, + "filterMarkers": "Filtra Marcatori", + "@filterMarkers": { + "description": "Titolo per la finestra filtra marcatori" + }, + "filterMarkersTooltip": "Filtra marcatori", + "@filterMarkersTooltip": { + "description": "Tooltip per il pulsante filtro" + }, + "contactsFilter": "Contatti", + "@contactsFilter": { + "description": "Opzione filtro per contatti" + }, + "repeatersFilter": "Ripetitori", + "@repeatersFilter": { + "description": "Opzione filtro per ripetitori" + }, + "sarMarkers": "Marcatori SAR", + "@sarMarkers": { + "description": "Intestazione sezione marcatori SAR" + }, + "foundPerson": "Persona Trovata", + "@foundPerson": { + "description": "Tipo marcatore SAR persona trovata" + }, + "fire": "Incendio", + "@fire": { + "description": "Tipo marcatore SAR incendio" + }, + "stagingArea": "Area di Appoggio", + "@stagingArea": { + "description": "Tipo marcatore SAR area di appoggio" + }, + "showAll": "Mostra Tutto", + "@showAll": { + "description": "Pulsante per mostrare tutti i filtri" + }, + "locationUnavailable": "Posizione non disponibile", + "@locationUnavailable": { + "description": "Messaggio quando la posizione GPS non è disponibile" + }, + "ahead": "avanti", + "@ahead": { + "description": "Direzione rilevamento relativo - avanti" + }, + "degreesRight": "{degrees}° destra", + "@degreesRight": { + "description": "Direzione rilevamento relativo - destra", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "{degrees}° sinistra", + "@degreesLeft": { + "description": "Direzione rilevamento relativo - sinistra", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "Lat: {latitude} Lon: {longitude}", + "@latLonFormat": { + "description": "Formato visualizzazione latitudine e longitudine", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "Nessun contatto ancora", + "@noContactsYet": { + "description": "Messaggio di stato vuoto quando non ci sono contatti" + }, + "connectToDeviceToLoadContacts": "Connetti a un dispositivo per caricare i contatti", + "@connectToDeviceToLoadContacts": { + "description": "Istruzione per connettere dispositivo per caricare contatti" + }, + "teamMembers": "Membri del Team", + "@teamMembers": { + "description": "Intestazione sezione per i membri del team (contatti chat)" + }, + "repeaters": "Ripetitori", + "@repeaters": { + "description": "Intestazione sezione per i nodi ripetitori" + }, + "rooms": "Stanze", + "@rooms": { + "description": "Intestazione sezione per le stanze" + }, + "channels": "Canali", + "@channels": { + "description": "Intestazione sezione per i canali di trasmissione" + }, + "selectMapLayer": "Seleziona Livello Mappa", + "@selectMapLayer": { + "description": "Titolo per la finestra di selezione livello mappa" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "Nome livello OpenStreetMap" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "Nome livello OpenTopoMap" + }, + "esriSatellite": "ESRI Satellite", + "@esriSatellite": { + "description": "Nome livello immagini satellitari ESRI" + }, + "googleHybrid": "Google Ibrido", + "@googleHybrid": { + "description": "Nome livello Google Ibrido (satellite + etichette)" + }, + "googleRoadmap": "Google Mappa Stradale", + "@googleRoadmap": { + "description": "Nome livello Google Mappa Stradale" + }, + "googleTerrain": "Google Terreno", + "@googleTerrain": { + "description": "Nome livello Google Terreno (topografico)" + }, + "dragToPosition": "Trascina per Posizionare", + "@dragToPosition": { + "description": "Etichetta quando si trascina un segnaposto sulla mappa" + }, + "createSarMarker": "Crea Marcatore SAR", + "@createSarMarker": { + "description": "Etichetta per creare marcatore SAR dal segnaposto" + }, + "compass": "Bussola", + "@compass": { + "description": "Titolo della bussola nella finestra bussola dettagliata" + }, + "navigationAndContacts": "Navigazione e Contatti", + "@navigationAndContacts": { + "description": "Sottotitolo per la finestra bussola" + }, + "sarAlert": "ALLERTA SAR", + "@sarAlert": { + "description": "Etichetta per il badge allerta SAR sui messaggi" + }, + "textCopiedToClipboard": "Testo copiato negli appunti", + "@textCopiedToClipboard": { + "description": "Messaggio di successo quando il testo viene copiato" + }, + "cannotReplySenderMissing": "Impossibile rispondere: informazioni mittente mancanti", + "@cannotReplySenderMissing": { + "description": "Errore quando le informazioni del mittente mancano per la risposta" + }, + "cannotReplyContactNotFound": "Impossibile rispondere: contatto non trovato", + "@cannotReplyContactNotFound": { + "description": "Errore quando il contatto non viene trovato per la risposta" + }, + "copyText": "Copia testo", + "deleteMessage": "Elimina messaggio", + "deleteMessageConfirmation": "Sei sicuro di voler eliminare questo messaggio?", + "shareLocation": "Condividi posizione", + "shareLocationText": "{markerInfo}\n\nCoordinate: {lat}, {lon}\n\nGoogle Maps: {url}", + "sarLocationShare": "Posizione SAR", + "justNow": "Proprio ora", + "@justNow": { + "description": "Indicatore tempo per attività molto recente" + }, + "minutesAgo": "{minutes}m fa", + "@minutesAgo": { + "description": "Indicatore tempo per minuti fa", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "{hours}h fa", + "@hoursAgo": { + "description": "Indicatore tempo per ore fa", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "{days}g fa", + "@daysAgo": { + "description": "Indicatore tempo per giorni fa", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "{seconds}s fa", + "@secondsAgo": { + "description": "Indicatore tempo per secondi fa", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "Invio...", + "@sending": { + "description": "Stato consegna: invio" + }, + "sent": "Inviato", + "@sent": { + "description": "Stato consegna: inviato" + }, + "delivered": "Consegnato", + "@delivered": { + "description": "Stato consegna: consegnato" + }, + "deliveredWithTime": "Consegnato ({time}ms)", + "@deliveredWithTime": { + "description": "Stato consegna con tempo di andata e ritorno", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "Fallito", + "@failed": { + "description": "Stato consegna: fallito" + }, + "broadcast": "Trasmissione", + "@broadcast": { + "description": "Stato di consegna per messaggi di canale (nessun eco ancora)" + }, + "deliveredToContacts": "Consegnato a {delivered}/{total} contatti", + "@deliveredToContacts": { + "description": "Conteggio consegna messaggi raggruppati", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "Tutto consegnato", + "@allDelivered": { + "description": "Stato quando tutti i destinatari hanno ricevuto il messaggio" + }, + "recipientDetails": "Dettagli destinatari", + "@recipientDetails": { + "description": "Intestazione per elenco destinatari espandibile" + }, + "pending": "In attesa", + "@pending": { + "description": "Stato consegna: in attesa" + }, + "sarMarkerFoundPerson": "Persona Trovata", + "@sarMarkerFoundPerson": { + "description": "Tipo marcatore SAR: persona trovata" + }, + "sarMarkerFire": "Posizione Incendio", + "@sarMarkerFire": { + "description": "Tipo marcatore SAR: incendio" + }, + "sarMarkerStagingArea": "Area di Appoggio", + "@sarMarkerStagingArea": { + "description": "Tipo marcatore SAR: area di appoggio" + }, + "sarMarkerObject": "Oggetto Trovato", + "@sarMarkerObject": { + "description": "Tipo marcatore SAR: oggetto" + }, + "from": "Da", + "@from": { + "description": "Etichetta mittente nelle notifiche" + }, + "coordinates": "Coordinate", + "@coordinates": { + "description": "Etichetta coordinate" + }, + "tapToViewOnMap": "Tocca per visualizzare sulla mappa", + "@tapToViewOnMap": { + "description": "Testo azione notifica" + }, + "radioSettings": "Impostazioni Radio", + "@radioSettings": { + "description": "Titolo sezione per le impostazioni radio" + }, + "frequencyMHz": "Frequenza (MHz)", + "@frequencyMHz": { + "description": "Etichetta per il campo frequenza radio" + }, + "bandwidth": "Larghezza di Banda", + "@bandwidth": { + "description": "Etichetta per il menu a discesa larghezza di banda" + }, + "spreadingFactor": "Fattore di Spreading", + "@spreadingFactor": { + "description": "Etichetta per il menu a discesa fattore di spreading" + }, + "codingRate": "Tasso di Codifica", + "@codingRate": { + "description": "Etichetta per il menu a discesa tasso di codifica" + }, + "txPowerDbm": "Potenza TX (dBm)", + "@txPowerDbm": { + "description": "Etichetta per il campo potenza TX" + }, + "maxPowerDbm": "Max: {power} dBm", + "@maxPowerDbm": { + "description": "Testo di aiuto che mostra la potenza TX massima", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "Tu", + "@you": { + "description": "Etichetta per l'utente corrente nelle bolle dei messaggi" + }, + "exportFailed": "Esportazione non riuscita: {error}", + "@exportFailed": { + "description": "Messaggio di errore quando esportazione fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "Importazione non riuscita: {error}", + "@importFailed": { + "description": "Messaggio di errore quando importazione fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "Sconosciuto", + "@unknown": { + "description": "Etichetta valore sconosciuto" + }, + "onlineLayers": "Livelli Online", + "@onlineLayers": { + "description": "Intestazione sezione per i livelli mappa online" + }, + "locationTrail": "Traccia Posizione", + "@locationTrail": { + "description": "Titolo traccia posizione" + }, + "showTrailOnMap": "Mostra Traccia sulla Mappa", + "@showTrailOnMap": { + "description": "Interruttore per mostrare/nascondere la traccia sulla mappa" + }, + "trailVisible": "La traccia è visibile sulla mappa", + "@trailVisible": { + "description": "Stato visibilità traccia - visibile" + }, + "trailHiddenRecording": "La traccia è nascosta (ancora in registrazione)", + "@trailHiddenRecording": { + "description": "Stato visibilità traccia - nascosta ma in registrazione" + }, + "duration": "Durata", + "@duration": { + "description": "Etichetta durata" + }, + "points": "Punti", + "@points": { + "description": "Etichetta conteggio punti traccia" + }, + "clearTrail": "Cancella Traccia", + "@clearTrail": { + "description": "Pulsante per cancellare la traccia posizione" + }, + "clearTrailQuestion": "Cancellare Traccia?", + "@clearTrailQuestion": { + "description": "Titolo finestra di conferma" + }, + "clearTrailConfirmation": "Sei sicuro di voler cancellare la traccia posizione attuale? Questa azione non può essere annullata.", + "@clearTrailConfirmation": { + "description": "Messaggio finestra di conferma" + }, + "noTrailRecorded": "Nessuna traccia registrata ancora", + "@noTrailRecorded": { + "description": "Messaggio quando non esiste traccia" + }, + "startTrackingToRecord": "Avvia il tracciamento posizione per registrare la tua traccia", + "@startTrackingToRecord": { + "description": "Istruzioni per avviare la registrazione della traccia" + }, + "trailControls": "Controlli Traccia", + "@trailControls": { + "description": "Tooltip controlli traccia" + }, + "model": "Modello", + "@model": { + "description": "Etichetta modello dispositivo" + }, + "version": "Versione", + "@version": { + "description": "Etichetta versione" + }, + "maxContacts": "Contatti Max", + "@maxContacts": { + "description": "Etichetta capacità massima contatti" + }, + "maxChannels": "Canali Max", + "@maxChannels": { + "description": "Etichetta capacità massima canali" + }, + "publicInfo": "Informazioni Pubbliche", + "@publicInfo": { + "description": "Intestazione sezione informazioni pubbliche" + }, + "useCurrentLocation": "Usa posizione attuale", + "@useCurrentLocation": { + "description": "Tooltip per il pulsante usa posizione attuale" + }, + "noneUnknown": "Nessuno/Sconosciuto", + "@noneUnknown": { + "description": "Tipo dispositivo: nessuno o sconosciuto" + }, + "chatNode": "Nodo Chat", + "@chatNode": { + "description": "Tipo dispositivo: nodo chat" + }, + "repeater": "Ripetitore", + "@repeater": { + "description": "Tipo dispositivo: ripetitore" + }, + "roomChannel": "Stanza/Canale", + "@roomChannel": { + "description": "Tipo dispositivo: stanza o canale" + }, + "typeNumber": "Tipo {number}", + "@typeNumber": { + "description": "Tipo dispositivo generico con numero", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "Impossibile salvare: {error}", + "@failedToSave": { + "description": "Messaggio di errore generico per fallimenti di salvataggio", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "Impossibile ottenere la posizione: {error}", + "@failedToGetLocation": { + "description": "Messaggio di errore quando l'ottenimento della posizione fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "Modelli SAR", + "addTemplate": "Aggiungi modello", + "editTemplate": "Modifica modello", + "deleteTemplate": "Elimina modello", + "templateName": "Nome modello", + "templateNameHint": "ad es. Persona trovata", + "templateEmoji": "Emoji", + "emojiRequired": "Emoji è obbligatorio", + "nameRequired": "Nome è obbligatorio", + "templateDescription": "Descrizione (facoltativa)", + "templateDescriptionHint": "Aggiungi contesto aggiuntivo...", + "templateColor": "Colore", + "previewFormat": "Anteprima (formato messaggio SAR)", + "importFromClipboard": "Importa", + "exportToClipboard": "Esporta", + "deleteTemplateConfirmation": "Eliminare il modello '{name}'?", + "templateAdded": "Modello aggiunto", + "templateUpdated": "Modello aggiornato", + "templateDeleted": "Modello eliminato", + "templatesImported": "{count, plural, =0{Nessun modello importato} =1{Importato 1 modello} other{Importati {count} modelli}}", + "templatesExported": "{count, plural, =1{Esportato 1 modello negli appunti} other{Esportati {count} modelli negli appunti}}", + "resetToDefaults": "Ripristina predefiniti", + "resetToDefaultsConfirmation": "Questo eliminerà tutti i modelli personalizzati e ripristinerà i 4 modelli predefiniti. Continuare?", + "reset": "Ripristina", + "resetComplete": "Modelli ripristinati ai valori predefiniti", + "noTemplates": "Nessun modello disponibile", + "tapAddToCreate": "Tocca + per creare il tuo primo modello", + "ok": "OK", + "locationPermission": "Permesso di posizione", + "checking": "Verifica in corso...", + "locationPermissionGrantedAlways": "Concesso (Sempre)", + "locationPermissionGrantedWhileInUse": "Concesso (Durante l'uso)", + "locationPermissionDeniedTapToRequest": "Negato - Tocca per richiedere", + "locationPermissionPermanentlyDeniedOpenSettings": "Negato permanentemente - Apri impostazioni", + "locationPermissionDialogContent": "Il permesso di posizione è permanentemente negato. Si prega di abilitarlo nelle impostazioni del dispositivo per utilizzare il tracciamento GPS e la condivisione della posizione.", + "openSettings": "Apri impostazioni", + "locationPermissionGranted": "Permesso di posizione concesso!", + "locationPermissionRequiredForGps": "Il permesso di posizione è necessario per il tracciamento GPS e la condivisione della posizione.", + "locationPermissionAlreadyGranted": "Il permesso di posizione è già concesso.", + "sarNavyBlue": "SAR Blu Navy", + "sarNavyBlueDescription": "Modalità Professionale/Operativa", + "selectRecipient": "Seleziona destinatario", + "broadcastToAllNearby": "Trasmetti a tutti nelle vicinanze", + "searchRecipients": "Cerca destinatari...", + "noContactsFound": "Nessun contatto trovato", + "noRoomsFound": "Nessuna stanza trovata", + "noRecipientsAvailable": "Nessun destinatario disponibile", + "noChannelsFound": "Nessun canale trovato", + "newMessage": "Nuovo messaggio", + "channel": "Canale", + "samplePoliceLead": "Capo della Polizia", + "sampleDroneOperator": "Operatore Drone", + "sampleFirefighterAlpha": "Vigile del Fuoco", + "sampleMedicCharlie": "Medico", + "sampleCommandDelta": "Comando", + "sampleFireEngine": "Autopompa", + "sampleAirSupport": "Supporto Aereo", + "sampleBaseCoordinator": "Coordinatore di Base", + "channelEmergency": "Emergenza", + "channelCoordination": "Coordinamento", + "channelUpdates": "Aggiornamenti", + "sampleTeamMember": "Membro del Team di Esempio", + "sampleScout": "Esploratore di Esempio", + "sampleBase": "Base di Esempio", + "sampleSearcher": "Cercatore di Esempio", + "sampleObjectBackpack": " Zaino trovato - colore blu", + "sampleObjectVehicle": " Veicolo abbandonato - controllare il proprietario", + "sampleObjectCamping": " Attrezzatura da campeggio scoperta", + "sampleObjectTrailMarker": " Segnavia trovato fuori sentiero", + "sampleMsgAllTeamsCheckIn": "Tutti i team segnalarsi", + "sampleMsgWeatherUpdate": "Aggiornamento meteo: Cielo sereno, temp 18°C", + "sampleMsgBaseCamp": "Campo base stabilito all'area di raduno", + "sampleMsgTeamAlpha": "Team si sta spostando al settore 2", + "sampleMsgRadioCheck": "Controllo radio - tutte le stazioni rispondano", + "sampleMsgWaterSupply": "Rifornimento idrico disponibile al punto di controllo 3", + "sampleMsgTeamBravo": "Team segnala: settore 1 libero", + "sampleMsgEtaRallyPoint": "ETA al punto di raduno: 15 minuti", + "sampleMsgSupplyDrop": "Lancio rifornimenti confermato per le 14:00", + "sampleMsgDroneSurvey": "Sorveglianza con drone completata - nessun ritrovamento", + "sampleMsgTeamCharlie": "Team richiede rinforzi", + "sampleMsgRadioDiscipline": "Tutte le unità: mantenere disciplina radio", + "sampleMsgUrgentMedical": "URGENTE: Assistenza medica necessaria al settore 4", + "sampleMsgAdultMale": " Uomo adulto, cosciente", + "sampleMsgFireSpotted": "Incendio avvistato - coordinate in arrivo", + "sampleMsgSpreadingRapidly": " Si sta diffondendo rapidamente!", + "sampleMsgPriorityHelicopter": "PRIORITÀ: Necessario supporto elicottero", + "sampleMsgMedicalTeamEnRoute": "Team medico in rotta verso la vostra posizione", + "sampleMsgEvacHelicopter": "Elicottero di evacuazione ETA 10 minuti", + "sampleMsgEmergencyResolved": "Emergenza risolta - tutto libero", + "sampleMsgEmergencyStagingArea": " Area di raduno di emergenza", + "sampleMsgEmergencyServices": "Servizi di emergenza notificati e in risposta", + "sampleAlphaTeamLead": "Capo Team", + "sampleBravoScout": "Esploratore", + "sampleCharlieMedic": "Medico", + "sampleDeltaNavigator": "Navigatore", + "sampleEchoSupport": "Supporto", + "sampleBaseCommand": "Comando di Base", + "sampleFieldCoordinator": "Coordinatore sul Campo", + "sampleMedicalTeam": "Team Medico", + "mapDrawing": "Disegno della Mappa", + "lineDrawing": "Linea", + "rectangleDrawing": "Rettangolo", + "navigateToDrawing": "Naviga al Disegno", + "hideFromMap": "Nascondi dalla Mappa", + "copyCoordinates": "Copia Coordinate", + "manualCoordinates": "Coordinate Manuali", + "enterCoordinatesManually": "Inserire le coordinate manualmente", + "latitudeLabel": "Latitudine", + "longitudeLabel": "Longitudine", + "exampleCoordinates": "Esempio: 46.0569, 14.5058", + "shareDrawing": "Condividi Disegno", + "shareWithAllNearbyDevices": "Condividi con tutti i dispositivi vicini", + "shareToRoom": "Condividi nella Stanza", + "sendToPersistentStorage": "Invia allo storage persistente della stanza", + "deleteDrawingConfirm": "Sei sicuro di voler eliminare questo disegno?", + "drawingDeleted": "Disegno eliminato", + "yourDrawingsCount": "I Tuoi Disegni ({count})", + "shared": "Condiviso", + "line": "Linea", + "rectangle": "Rettangolo", + "saveAsTemplate": "Salva come Modello", + "templateSaved": "Modello salvato con successo", + "templateAlreadyExists": "Esiste già un modello con questa emoji", + "updateAvailable": "Aggiornamento Disponibile", + "currentVersion": "Attuale", + "latestVersion": "Ultima", + "downloadUpdate": "Scarica", + "updateLater": "Più Tardi", + "cadastralParcels": "Particelle Catastali", + "forestRoads": "Strade Forestali", + "wmsOverlays": "Sovrapposizioni WMS", + "hikingTrails": "Sentieri Escursionistici", + "mainRoads": "Strade Principali", + "houseNumbers": "Numeri Civici", + "fireHazardZones": "Zone a Rischio Incendio", + "historicalFires": "Incendi Storici", + "firebreaks": "Fasce Tagliafuoco", + "krasFireZones": "Zone di Incendio Kras", + "placeNames": "Nomi di Luoghi", + "municipalityBorders": "Confini Comunali", + "topographicMap": "Carta Topografica 1:25000", + "recentMessages": "Messaggi Recenti", + "@recentMessages": { + "description": "Header for recent messages overlay on map in fullscreen mode" + }, + "addChannel": "Aggiungi Canale", + "channelName": "Nome del Canale", + "channelNameHint": "es. Squadra di Soccorso Alfa", + "channelSecret": "Password del Canale", + "channelSecretHint": "Password condivisa per questo canale", + "channelSecretHelp": "Questa password deve essere condivisa con tutti i membri del team che necessitano di accesso a questo canale", + "channelTypesInfo": "Canali hash (#squadra): Password generata automaticamente dal nome. Stesso nome = stesso canale su tutti i dispositivi.\n\nCanali privati: Usa una password esplicita. Solo chi ha la password può unirsi.", + "hashChannelInfo": "Canale hash: La password verrà generata automaticamente dal nome del canale. Chiunque utilizzi lo stesso nome si unirà allo stesso canale.", + "channelNameRequired": "Il nome del canale è obbligatorio", + "channelNameTooLong": "Il nome del canale deve contenere al massimo 31 caratteri", + "channelSecretRequired": "La password del canale è obbligatoria", + "channelSecretTooLong": "La password del canale deve contenere al massimo 32 caratteri", + "invalidAsciiCharacters": "Sono consentiti solo caratteri ASCII", + "channelCreatedSuccessfully": "Canale creato con successo", + "channelCreationFailed": "Creazione del canale fallita: {error}", + "deleteChannel": "Elimina Canale", + "deleteChannelConfirmation": "Sei sicuro di voler eliminare il canale \"{channelName}\"? Questa azione non può essere annullata.", + "channelDeletedSuccessfully": "Canale eliminato con successo", + "channelDeletionFailed": "Eliminazione del canale fallita: {error}", + "createChannel": "Crea Canale", + "wizardBack": "Indietro", + "wizardSkip": "Salta", + "wizardNext": "Avanti", + "wizardGetStarted": "Inizia", + "wizardWelcomeTitle": "Benvenuto in MeshCore SAR", + "viewWelcomeTutorial": "Visualizza tutorial di benvenuto", + "allTeamContacts": "Tutti i contatti del team", + "directMessagesInfo": "Messaggi diretti con conferme. Inviato a {count} membri del team.", + "sarMarkerSentToContacts": "Marcatore SAR inviato a {count} contatti", + "noContactsAvailable": "Nessun contatto del team disponibile", + "reply": "Rispondi", + "technicalDetails": "Dettagli tecnici", + "messageTechnicalDetails": "Dettagli tecnici del messaggio", + "linkQuality": "Qualità del collegamento", + "delivery": "Consegna", + "status": "Stato", + "expectedAckTag": "Tag ACK atteso", + "roundTrip": "Andata e ritorno", + "retryAttempt": "Tentativo di nuovo invio", + "floodFallback": "Fallback inondazione", + "identity": "Identità", + "messageId": "ID messaggio", + "sender": "Mittente", + "senderKey": "Chiave mittente", + "recipient": "Destinatario", + "recipientKey": "Chiave destinatario", + "voice": "Voce", + "voiceId": "ID vocale", + "envelope": "Busta", + "sessionProgress": "Avanzamento sessione", + "complete": "Completato", + "rawDump": "Dump grezzo", + "cannotRetryMissingRecipient": "Impossibile riprovare: informazioni destinatario mancanti", + "voiceUnavailable": "Voce non disponibile al momento", + "requestingVoice": "Richiesta voce", + "device": "dispositivo", + "change": "Modifica", + "wizardOverviewDescription": "Questa app riunisce messaggistica MeshCore, aggiornamenti SAR sul campo, mappe e strumenti del dispositivo in un unico posto.", + "wizardOverviewFeature1": "Invia messaggi diretti, post nelle stanze e messaggi di canale dalla scheda principale Messaggi.", + "wizardOverviewFeature2": "Condividi marcatori SAR, disegni sulla mappa, clip vocali e immagini sulla mesh.", + "wizardOverviewFeature3": "Connettiti tramite BLE o TCP e poi gestisci la radio companion direttamente dall'app.", + "wizardMessagingTitle": "Messaggistica e rapporti sul campo", + "wizardMessagingDescription": "Qui i messaggi sono più del semplice testo. L'app supporta già diversi payload operativi e flussi di trasferimento.", + "wizardMessagingFeature1": "Invia messaggi diretti, post nelle stanze e traffico di canale da un unico compositore.", + "wizardMessagingFeature2": "Crea aggiornamenti SAR e modelli SAR riutilizzabili per i rapporti sul campo più comuni.", + "wizardMessagingFeature3": "Trasferisci sessioni vocali e immagini, con avanzamento e stime del tempo d'aria nell'interfaccia.", + "wizardConnectDeviceTitle": "Connetti dispositivo", + "wizardConnectDeviceDescription": "Connetti la tua radio MeshCore, scegli un nome e applica un preset radio prima di continuare.", + "wizardSetupBadge": "Configurazione", + "wizardOverviewBadge": "Panoramica", + "wizardConnectedToDevice": "Connesso a {deviceName}", + "wizardNoDeviceConnected": "Nessun dispositivo ancora connesso", + "wizardSkipForNow": "Salta per ora", + "wizardDeviceNameLabel": "Nome del dispositivo", + "wizardDeviceNameHelp": "Questo nome viene annunciato agli altri utenti MeshCore.", + "wizardConfigRegionLabel": "Regione di configurazione", + "wizardConfigRegionHelp": "Usa l'elenco ufficiale completo dei preset MeshCore. Il valore predefinito è EU/UK (Narrow).", + "wizardPresetNote1": "Assicurati che il preset selezionato corrisponda alle normative radio locali.", + "wizardPresetNote2": "L'elenco corrisponde al feed ufficiale dei preset dello strumento di configurazione MeshCore.", + "wizardPresetNote3": "EU/UK (Narrow) resta selezionato per impostazione predefinita durante l'onboarding.", + "wizardSaving": "Salvataggio...", + "wizardSaveAndContinue": "Salva e continua", + "wizardEnterDeviceName": "Inserisci un nome dispositivo prima di continuare.", + "wizardDeviceSetupSaved": "{deviceName} salvato con {presetName}.", + "wizardNetworkTitle": "Contatti, stanze e ripetitori", + "wizardNetworkDescription": "La scheda Contatti organizza la rete che scopri e i percorsi che apprendi nel tempo.", + "wizardNetworkFeature1": "Controlla membri del team, ripetitori, stanze, canali e annunci in sospeso in un unico elenco.", + "wizardNetworkFeature2": "Usa smart ping, accesso alle stanze, percorsi appresi e strumenti di reset delle route quando la connettività diventa complicata.", + "wizardNetworkFeature3": "Crea canali e gestisci le destinazioni di rete senza uscire dall'app.", + "wizardMapOpsTitle": "Mappa, tracce e geometria condivisa", + "wizardMapOpsDescription": "La mappa dell'app è collegata direttamente a messaggistica, tracciamento e overlay SAR invece di essere un visualizzatore separato.", + "wizardMapOpsFeature1": "Monitora la tua posizione, le posizioni dei compagni e le tracce di movimento sulla mappa.", + "wizardMapOpsFeature2": "Apri i disegni dai messaggi, visualizzali in anteprima in linea e rimuovili dalla mappa quando serve.", + "wizardMapOpsFeature3": "Usa le viste mappa dei ripetitori e gli overlay condivisi per capire la copertura della rete sul campo.", + "wizardToolsTitle": "Strumenti oltre la messaggistica", + "wizardToolsDescription": "Qui c'è più delle quattro schede principali. L'app include anche configurazione, diagnostica e flussi sensori opzionali.", + "wizardToolsFeature1": "Apri la configurazione del dispositivo per cambiare impostazioni radio, telemetria, potenza TX e dettagli del companion.", + "wizardToolsFeature2": "Abilita la scheda Sensori quando vuoi dashboard monitorate e azioni di aggiornamento rapido.", + "wizardToolsFeature3": "Usa log dei pacchetti, scansione dello spettro e diagnostica sviluppatore per risolvere problemi della mesh.", + "contactInSensors": "Nei Sensori", + "contactAddToSensors": "Aggiungi ai Sensori", + "contactSetPath": "Imposta percorso", + "contactAddedToSensors": "{contactName} aggiunto a Sensori", + "contactFailedToClearRoute": "Impossibile cancellare il percorso: {error}", + "contactRouteCleared": "Percorso cancellato", + "contactRouteSet": "Percorso impostato: {route}", + "contactFailedToSetRoute": "Impossibile impostare il percorso: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "Timeout ACK", + "opcode": "Opcode", + "payload": "Payload", + "hops": "Hop", + "hashSize": "Dimensione hash", + "pathBytes": "Byte percorso", + "selectedPath": "Percorso selezionato", + "estimatedTx": "Trasmissione stimata", + "senderToReceipt": "Mittente a ricevuta", + "receivedCopies": "Copie ricevute", + "retryCause": "Causa del ritentativo", + "retryMode": "Modalità ritentativo", + "retryResult": "Risultato ritentativo", + "lastRetry": "Ultimo ritentativo", + "rxPackets": "Pacchetti RX", + "mesh": "Mesh", + "rate": "Velocità", + "window": "Finestra", + "posttxDelay": "Ritardo post-tx", + "bandpass": "Passa-banda", + "bandpassFilterVoice": "Filtro passa-banda voce", + "active": "Attivo", + "addContact": "Aggiungi contatto", + "all": "Tutto", + "clearAllLabel": "Cancella tutto", + "clearFilters": "Cancella filtri", + "clearRoute": "Cancella percorso", + "clearMessages": "Cancella messaggi", + "clearScale": "Cancella scala", + "clearDiscoveries": "Cancella scoperte", + "clearOnlineTraceDatabase": "Cancella database tracce", + "clearAllChannels": "Cancella tutti i canali", + "clearAllContacts": "Cancella tutti i contatti", + "clearChannels": "Cancella canali", + "clearContacts": "Cancella contatti", + "clearPathOnMaxRetry": "Cancella percorso al max ritentativo", + "create": "Crea", + "custom": "Personalizzato", + "defaultValue": "Predefinito", + "duplicate": "Duplica", + "editName": "Modifica nome", + "open": "Apri", + "paste": "Incolla", + "preview": "Anteprima", + "remove": "Rimuovi", + "rename": "Rinomina", + "resolveAll": "Risolvi tutto", + "send": "Invia", + "sendAnyway": "Invia comunque", + "share": "Condividi", + "trace": "Traccia", + "discovery": "Scoperta", + "discoverRepeaters": "Scopri ripetitori", + "discoverSensors": "Scopri sensori", + "repeaterDiscoverySent": "Scoperta ripetitori inviata", + "sensorDiscoverySent": "Scoperta sensori inviata", + "clearedPendingDiscoveries": "Scoperte in attesa cancellate.", + "autoDiscovery": "Scoperta automatica", + "enableAutomaticAdding": "Abilita aggiunta automatica", + "autoaddRepeaters": "Aggiungi ripetitori automaticamente", + "autoaddRoomServers": "Aggiungi server stanze automaticamente", + "autoaddSensors": "Aggiungi sensori automaticamente", + "autoaddUsers": "Aggiungi utenti automaticamente", + "overwriteOldestWhenFull": "Sovrascrivi i più vecchi quando pieno", + "storage": "Archiviazione", + "dangerZone": "Zona pericolosa", + "profiles": "Profili", + "favourites": "Preferiti", + "sensors": "Sensori", + "others": "Altri", + "gpsModule": "Modulo GPS", + "liveTraffic": "Traffico in tempo reale", + "repeatersMap": "Mappa ripetitori", + "blePacketLogs": "Log pacchetti BLE", + "onlineTraceDatabase": "Database tracce", + "routePathByteSize": "Dimensione percorso in byte", + "messageNotifications": "Notifiche messaggi", + "sarAlerts": "Avvisi SAR", + "discoveryNotifications": "Notifiche scoperte", + "updateNotifications": "Notifiche aggiornamenti", + "muteWhileAppIsOpen": "Silenzia con l'app aperta", + "disableContacts": "Disabilita contatti", + "enableSensorsTab": "Abilita scheda Sensori", + "enableProfiles": "Abilita profili", + "nearestRepeaterFallback": "Ripetitore più vicino come fallback", + "deleteAllStoredMessageHistory": "Elimina tutta la cronologia messaggi", + "messageFontSize": "Dimensione carattere messaggi", + "rotateMapWithHeading": "Ruota mappa con direzione", + "showMapDebugInfo": "Mostra info debug mappa", + "openMapInFullscreen": "Apri mappa a schermo intero", + "showSarMarkersLabel": "Mostra marcatori SAR", + "hideRepeatersOnMap": "Nascondi ripetitori sulla mappa", + "setMapScale": "Imposta scala mappa", + "customMapScaleSaved": "Scala mappa personalizzata salvata", + "voiceBitrate": "Bitrate voce", + "voiceCompressor": "Compressore voce", + "voiceLimiter": "Limitatore voce", + "micAutoGain": "Guadagno automatico microfono", + "echoCancellation": "Cancellazione eco", + "noiseSuppression": "Soppressione rumore", + "trimSilenceInVoiceMessages": "Taglia silenzio nei messaggi vocali", + "compressor": "Compressore", + "limiter": "Limitatore", + "autoGain": "Guadagno automatico", + "echoCancel": "Eco", + "noiseSuppress": "Rumore", + "silenceTrim": "Silenzio", + "maxImageSize": "Dimensione massima immagine", + "imageCompression": "Compressione immagine", + "grayscale": "Scala di grigi", + "ultraMode": "Modalità ultra", + "fastPrivateGpsUpdates": "Aggiornamenti GPS privati veloci", + "movementThreshold": "Soglia di movimento", + "fastGpsMovementThreshold": "Soglia movimento GPS veloce", + "fastGpsActiveuseInterval": "Intervallo uso attivo GPS veloce", + "activeuseUpdateInterval": "Intervallo aggiornamento uso attivo", + "repeatNearbyTraffic": "Ripeti traffico vicino", + "relayThroughRepeatersAcrossTheMesh": "Inoltra tramite ripetitori nella rete", + "nearbyOnlyWithoutRepeaterFlooding": "Solo vicini, senza flood dei ripetitori", + "multihop": "Multi-hop", + "createProfile": "Crea profilo", + "renameProfile": "Rinomina profilo", + "newProfile": "Nuovo profilo", + "manageProfiles": "Gestisci profili", + "enableProfilesToStartManagingThem": "Abilita i profili per iniziare a gestirli.", + "openMessage": "Apri messaggio", + "jumpToTheRelatedSarMessage": "Vai al messaggio SAR correlato", + "removeSarMarker": "Rimuovi marcatore SAR", + "pleaseSelectADestinationToSendSarMarker": "Seleziona una destinazione per inviare il marcatore SAR", + "sarMarkerBroadcastToPublicChannel": "Marcatore SAR trasmesso al canale pubblico", + "sarMarkerSentToRoom": "Marcatore SAR inviato alla stanza", + "loadFromGallery": "Carica dalla galleria", + "replaceImage": "Sostituisci immagine", + "selectFromGallery": "Seleziona dalla galleria", + "team": "Squadra", + "found": "Trovato", + "staging": "Area di raccolta", + "object": "Oggetto", + "quiet": "Silenzioso", + "busy": "Occupato", + "searchMessages": "Cerca messaggi", + "sendImageFromGallery": "Invia immagine dalla galleria", + "takePhoto": "Scatta foto", + "allMessages": "Tutti i messaggi", + "sendToPublicChannel": "Inviare al canale pubblico?", + "selectMarkerTypeAndDestination": "Seleziona tipo marcatore e destinazione", + "noDestinationsAvailableLabel": "Nessuna destinazione disponibile", + "image": "Immagine", + "format": "Formato", + "dimensions": "Dimensioni", + "segments": "Segmenti", + "transfers": "Trasferimenti", + "downloadedBy": "Scaricato da", + "saveDiscoverySettings": "Salva impostazioni scoperta", + "savePublicInfo": "Salva info pubbliche", + "saveRadioSettings": "Salva impostazioni radio", + "savePath": "Salva percorso", + "wipeDeviceData": "Cancella dati dispositivo", + "wipeDevice": "Cancella dispositivo", + "destructiveDeviceActions": "Azioni distruttive del dispositivo.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Scegli un preset o personalizza le impostazioni radio.", + "used": "Usato", + "total": "Totale", + "renameValue": "Rinomina valore", + "customizeFields": "Personalizza campi", + "livePreview": "Anteprima dal vivo", + "refreshSchedule": "Programma aggiornamento", + "noResponse": "Nessuna risposta", + "refreshing": "Aggiornamento", + "unavailable": "Non disponibile", + "pickARelayOrNodeToWatchInSensors": "Scegli un relay o nodo da monitorare.", + "publicKeyLabel": "Chiave pubblica", + "alreadyInContacts": "Già nei contatti", + "connectToADeviceBeforeAddingContacts": "Connettiti a un dispositivo prima di aggiungere contatti", + "fromContacts": "Dai contatti", + "onlineOnly": "Solo online", + "inBoth": "In entrambi", + "source": "Origine", + "allMessagesCleared": "Tutti i messaggi cancellati", + "onlineTraceDatabaseCleared": "Database tracce cancellata", + "packetLogsCleared": "Log pacchetti cancellati", + "hexDataCopiedToClipboard": "Dati hex copiati negli appunti", + "developerModeEnabled": "Modalità sviluppatore attivata", + "developerModeDisabled": "Modalità sviluppatore disattivata", + "clipboardIsEmpty": "Gli appunti sono vuoti", + "contactImported": "Contatto importato", + "contactLinkCopiedToClipboard": "Link contatto copiato negli appunti", + "failedToExportContact": "Esportazione contatto fallita", + "noLogsToExport": "Nessun log da esportare", + "exportAsCsv": "Esporta come CSV", + "exportAsText": "Esporta come testo", + "receivedRfc3339": "Ricevuto (RFC3339)", + "buildTime": "Ora build", + "downloadUrlNotAvailable": "URL di download non disponibile", + "cannotOpenDownloadUrl": "Impossibile aprire URL di download", + "updateCheckIsOnlyAvailableOnAndroid": "Il controllo aggiornamenti è disponibile solo su Android", + "youAreRunningTheLatestVersion": "Stai utilizzando l'ultima versione", + "updateAvailableButDownloadUrlNotFound": "Aggiornamento disponibile ma URL di download non trovato", + "startTictactoe": "Avvia Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe non disponibile", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: avversario sconosciuto", + "tictactoeWaitingForStart": "Tic-Tac-Toe: in attesa dell'inizio", + "acceptsShareLinks": "Accetta link condivisi", + "supportsRawHex": "Supporta hex grezzo", + "clipboardfriendly": "Compatibile con appunti", + "captured": "Catturato", + "size": "Dimensione", + "noCustomChannelsToClear": "Nessun canale personalizzato da cancellare.", + "noDeviceContactsToClear": "Nessun contatto dispositivo da cancellare.", + "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", + "simpleMode": "Modalità semplice", + "simpleModeDescription": "Solo chat e mappa con i contatti preferiti", + "simpleModeRequiresLockedDestination": "Attiva prima «Blocca destinazione» con un canale per usare la modalità semplice", + "noFavouriteContacts": "Nessun contatto preferito", + "selectDestinationFirst": "Seleziona prima un canale, un contatto o una stanza", + "recipientNoLongerAvailable": "Il destinatario non è più disponibile", + "failedToSendMessage": "Invio non riuscito: {error}", + "tictactoeDirectOnly": "Il tris funziona solo nei messaggi diretti. Scegli prima un contatto.", + "deviceKeyUnavailable": "La chiave del dispositivo non è disponibile", + "imageCompressionFailed": "Compressione dell'immagine non riuscita", + "imageFragmentationFailed": "Frammentazione dell'immagine non riuscita", + "failedToAnnounceImage": "Annuncio dell'immagine non riuscito", + "noRecipientSelected": "Nessun destinatario selezionato", + "imageSendFailed": "Invio dell'immagine non riuscito", + "microphonePermissionRequiredForVoice": "Per i messaggi vocali è necessario il permesso del microfono", + "recordingDiscarded": "Registrazione scartata", + "publicChannelMediaWarning": "Stai per inviare {mediaType} al Canale Pubblico. Non è consigliato, perché chiunque sulla rete mesh potrebbe riceverlo. Scegli un canale privato o con tag, a meno che non sia ciò che vuoi.", + "selectPrivateChannelToShareLocation": "Seleziona un canale privato per condividere la tua posizione.", + "stopSharingMyLocation": "Smetti di condividere la mia posizione", + "shareMyLocation": "Condividi la mia posizione", + "stopRecording": "Interrompi registrazione", + "recordVoice": "Registra messaggio vocale", + "moreActions": "Altre azioni", + "noMessagesInFilter": "Nessun messaggio in questo filtro", + "noMatchesInFilter": "Nessuna corrispondenza in questo filtro", + "syncFailed": "Sincronizzazione non riuscita: {error}", + "invalidLink": "Link non valido", + "cannotOpenLink": "Impossibile aprire il link", + "failedToOpenLink": "Apertura del link non riuscita", + "failedToResendMessage": "Reinvio del messaggio non riuscito", + "retryFailed": "Nuovo tentativo non riuscito: {error}", + "deleteDrawingAndMessage": "Elimina disegno e messaggio", + "lastEchoRelay": "Ultimo eco: relay", + "lastEchoPath": "Ultimo eco: percorso", + "lastEchoBytesReport": "Ultimo eco: report byte", + "noGpsCoordinatesAvailable": "Nessuna coordinata GPS disponibile", + "notASarMarker": "Non è un marcatore SAR", + "failedToSaveTemplate": "Salvataggio del modello non riuscito: {error}", + "drawingNotFound": "Disegno non trovato", + "unknownDrawingType": "Tipo di disegno sconosciuto", + "customMapMarker": "Marcatore mappa personalizzato", + "customMapPointLabel": "Punto: {point}", + "mapIdLabel": "ID mappa: {id}", + "cannotFetchVoice": "Impossibile recuperare il vocale", + "senderContactUnknown": "Il contatto del mittente è sconosciuto. Sincronizza prima i contatti.", + "senderRouteUnknown": "Il percorso del mittente è sconosciuto. Sincronizza prima contatti/percorso.", + "messageTooFar": "Il messaggio è troppo lontano ({hops} hop, max {maxHops}).", + "senderRouteNoPathResponse": "Il percorso del mittente non ha risposto alla verifica. Sincronizza contatti/percorso e riprova.", + "senderRouteNoRawResponse": "Il percorso del mittente non ha risposto sul percorso di trasporto raw.", + "voiceFetchOverHops": "Il recupero del vocale su {hops} hop può richiedere un po' di tempo.", + "voiceReceiveCanceled": "Ricezione vocale annullata", + "fetchingMissingVoiceFragments": "Recupero dei frammenti vocali mancanti", + "receivingVoice": "Ricezione vocale", + "cannotFetchImage": "Impossibile recuperare l'immagine", + "imageFetchOverHops": "Il recupero dell'immagine su {hops} hop può richiedere un po' di tempo.", + "imageAlreadyBeingReceived": "L'immagine è già in ricezione", + "loadImage": "Carica immagine", + "imageFetchFailedToSendRequest": "Invio della richiesta immagine non riuscito", + "imageUnavailable": "Immagine al momento non disponibile", + "imageFetchTimedOut": "Recupero dell'immagine scaduto", + "imageReceiveCanceled": "Ricezione dell'immagine annullata", + "closeImagePreview": "Chiudi anteprima immagine", + "recordingReleaseToSend": "Registrazione... rilascia per inviare", + "sendingVoice": "Invio vocale...", + "sendLongPressToRecordVoice": "Invia (pressione lunga per registrare un vocale)", + "discardRecording": "Scarta registrazione", + "hopCount": "{count, plural, =1{1 hop} other{{count} hop}}", + "channelsSectionDescription": "Canali broadcast per il traffico mesh nelle vicinanze", + "roomsSectionDescription": "Spazi condivisi per il coordinamento continuo del team", + "contactsSectionDescription": "Persone e dispositivi raggiungibili direttamente", + "noRecentChatters": "Nessun partecipante recente", + "public": "Pubblico", + "failedToSendTicTacToeMove": "Invio della mossa di tris non riuscito", + "youWon": "Hai vinto", + "opponentWon": "Ha vinto l'avversario", + "gameDraw": "Pareggio", + "yourTurn": "Tocca a te", + "opponentTurn": "Tocca all'avversario", + "pleaseEnterValidCoordinates": "Inserisci coordinate valide", + "locationNotAvailable": "Posizione non disponibile", + "customCaveMapPoint": "Punto mappa grotta personalizzato", + "mapPoint": "Punto mappa", + "fetchingMissingImageFragments": "Recupero frammenti mancanti…", + "loadingImage": "Caricamento…", + "receivingImage": "Ricezione…", + "tapToLoad": "Tocca per caricare", + "connectDevice": "Connetti dispositivo", + "chooseTransportSubtitle": "Scegli il trasporto Bluetooth, WiFi o Seriale", + "ble": "BLE", + "network": "Rete", + "serial": "Seriale", + "scan": "Cerca", + "pressScanToSearchForDevices": "Premi Cerca per trovare i dispositivi nelle vicinanze", + "failedToConnectToDevice": "Impossibile connettersi a {name}", + "unknownDevice": "Dispositivo sconosciuto", + "showingCachedResultsTapRefresh": "Risultati in cache. Tocca Aggiorna per ripetere la scansione.", + "scanningLocalNetworkOnPort": "Scansione della rete locale per dispositivi MeshCore WiFi sulla porta {port}", + "cancelScanAndAddServer": "Annulla la scansione e aggiungi un server", + "addServer": "Aggiungi server", + "scanningProgressIps": "Scansione... {scanned}/{total} IP", + "cancelAndEnterManually": "Annulla e inserisci manualmente", + "noRecentOrDiscoveredServers": "Nessun server recente o rilevato per ora", + "recentlyUsed": "Usati di recente", + "connecting": "Connessione...", + "discoveredOnThisNetwork": "Rilevati su questa rete", + "serverNoLongerAvailable": "Il server {host}:{port} non è più disponibile. Esegui di nuovo la scansione per trovare server attivi.", + "failedToConnectToHost": "Impossibile connettersi a {host}:{port}", + "enterValidIpAddress": "Inserisci un indirizzo IP valido", + "enterValidTcpPort": "Inserisci una porta TCP valida", + "ipAddress": "Indirizzo IP", + "tcpPort": "Porta TCP", + "customServerPort": "Porta server personalizzata", + "chat": "Chat", + "restoringPreviousLink": "Ripristino del collegamento precedente", + "noDeviceConnected": "Nessun dispositivo connesso", + "sendAdvert": "Invia il mio contatto", + "advertMode": "Invia il mio contatto", + "advertModeSubtitle": "Scegli quanto lontano devono viaggiare le tue informazioni di contatto.", + "floodAdvertSent": "Contatto inviato attraverso la rete mesh", + "directAdvertSent": "Contatto inviato ai dispositivi vicini" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..1fba915 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,6380 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_de.dart'; +import 'app_localizations_el.dart'; +import 'app_localizations_en.dart'; +import 'app_localizations_es.dart'; +import 'app_localizations_fr.dart'; +import 'app_localizations_hr.dart'; +import 'app_localizations_it.dart'; +import 'app_localizations_pl.dart'; +import 'app_localizations_pt.dart'; +import 'app_localizations_ru.dart'; +import 'app_localizations_sl.dart'; +import 'app_localizations_tr.dart'; +import 'app_localizations_uk.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('de'), + Locale('el'), + Locale('en'), + Locale('es'), + Locale('fr'), + Locale('hr'), + Locale('it'), + Locale('pl'), + Locale('pt'), + Locale('ru'), + Locale('sl'), + Locale('tr'), + Locale('uk'), + Locale('zh'), + ]; + + /// The application title + /// + /// In en, this message translates to: + /// **'MeshCore SAR'** + String get appTitle; + + /// Messages tab label + /// + /// In en, this message translates to: + /// **'Messages'** + String get messages; + + /// Contacts tab label + /// + /// In en, this message translates to: + /// **'Contacts'** + String get contacts; + + /// Map tab label + /// + /// In en, this message translates to: + /// **'Map'** + String get map; + + /// Settings screen title + /// + /// In en, this message translates to: + /// **'Settings'** + String get settings; + + /// Connect button label + /// + /// In en, this message translates to: + /// **'Connect'** + String get connect; + + /// Disconnect button label + /// + /// In en, this message translates to: + /// **'Disconnect'** + String get disconnect; + + /// Text shown when no BLE devices are found + /// + /// In en, this message translates to: + /// **'No devices found'** + String get noDevicesFound; + + /// Button to restart BLE scanning + /// + /// In en, this message translates to: + /// **'Scan Again'** + String get scanAgain; + + /// Error message when device is not connected + /// + /// In en, this message translates to: + /// **'Device not connected'** + String get deviceNotConnected; + + /// Error when location permission is denied + /// + /// In en, this message translates to: + /// **'Location permission denied'** + String get locationPermissionDenied; + + /// Error when location permission is permanently denied + /// + /// In en, this message translates to: + /// **'Location permission permanently denied. Please enable in Settings.'** + String get locationPermissionPermanentlyDenied; + + /// Message when location permission is needed + /// + /// In en, this message translates to: + /// **'Location permission is required for GPS tracking and team coordination. You can enable it later in Settings.'** + String get locationPermissionRequired; + + /// Error when location services are disabled + /// + /// In en, this message translates to: + /// **'Location services are disabled. Please enable them in Settings.'** + String get locationServicesDisabled; + + /// Error when GPS location cannot be obtained + /// + /// In en, this message translates to: + /// **'Failed to get GPS location'** + String get failedToGetGpsLocation; + + /// Error message for failed advertisement + /// + /// In en, this message translates to: + /// **'Failed to advertise: {error}'** + String failedToAdvertise(String error); + + /// Tooltip for cancel reconnection button + /// + /// In en, this message translates to: + /// **'Cancel reconnection'** + String get cancelReconnection; + + /// General settings section header + /// + /// In en, this message translates to: + /// **'General'** + String get general; + + /// Theme setting label + /// + /// In en, this message translates to: + /// **'Theme'** + String get theme; + + /// Theme selection dialog title + /// + /// In en, this message translates to: + /// **'Choose Theme'** + String get chooseTheme; + + /// Light theme option + /// + /// In en, this message translates to: + /// **'Light'** + String get light; + + /// Dark theme option + /// + /// In en, this message translates to: + /// **'Dark'** + String get dark; + + /// Description for blue light theme + /// + /// In en, this message translates to: + /// **'Blue light theme'** + String get blueLightTheme; + + /// Description for blue dark theme + /// + /// In en, this message translates to: + /// **'Blue dark theme'** + String get blueDarkTheme; + + /// SAR Red theme option + /// + /// In en, this message translates to: + /// **'SAR Red'** + String get sarRed; + + /// Description for SAR Red theme + /// + /// In en, this message translates to: + /// **'Alert/Emergency mode'** + String get alertEmergencyMode; + + /// SAR Green theme option + /// + /// In en, this message translates to: + /// **'SAR Green'** + String get sarGreen; + + /// Description for SAR Green theme + /// + /// In en, this message translates to: + /// **'Safe/All Clear mode'** + String get safeAllClearMode; + + /// Auto/System theme option + /// + /// In en, this message translates to: + /// **'Auto (System)'** + String get autoSystem; + + /// Description for system theme + /// + /// In en, this message translates to: + /// **'Follow system theme'** + String get followSystemTheme; + + /// Setting to show RX/TX indicators + /// + /// In en, this message translates to: + /// **'Show RX/TX Indicators'** + String get showRxTxIndicators; + + /// Setting to disable the map tab + /// + /// In en, this message translates to: + /// **'Disable Map'** + String get disableMap; + + /// Language setting label + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// Language selection dialog title + /// + /// In en, this message translates to: + /// **'Choose Language'** + String get chooseLanguage; + + /// Save button label + /// + /// In en, this message translates to: + /// **'Save'** + String get save; + + /// Cancel button label + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// Close button label + /// + /// In en, this message translates to: + /// **'Close'** + String get close; + + /// About section header + /// + /// In en, this message translates to: + /// **'About'** + String get about; + + /// App version label + /// + /// In en, this message translates to: + /// **'App Version'** + String get appVersion; + + /// About dialog title + /// + /// In en, this message translates to: + /// **'About MeshCore SAR'** + String get aboutMeshCoreSar; + + /// About dialog description + /// + /// In en, this message translates to: + /// **'A Search & Rescue application designed for emergency response teams. Features include:\n\n• BLE mesh networking for device-to-device communication\n• Offline maps with multiple layer options\n• Real-time team member tracking\n• SAR tactical markers (found person, fire, staging)\n• Contact management and messaging\n• GPS tracking with compass heading\n• Map tile caching for offline use'** + String get aboutDescription; + + /// Technologies used section title + /// + /// In en, this message translates to: + /// **'Technologies Used:'** + String get technologiesUsed; + + /// List of technologies used + /// + /// In en, this message translates to: + /// **'• Flutter for cross-platform development\n• BLE (Bluetooth Low Energy) for mesh networking\n• OpenStreetMap for mapping\n• Provider for state management\n• SharedPreferences for local storage'** + String get technologiesList; + + /// More info button label + /// + /// In en, this message translates to: + /// **'More Info'** + String get moreInfo; + + /// Package name label + /// + /// In en, this message translates to: + /// **'Package Name'** + String get packageName; + + /// Sample data section header + /// + /// In en, this message translates to: + /// **'Sample Data'** + String get sampleData; + + /// Sample data section description + /// + /// In en, this message translates to: + /// **'Load or clear sample contacts, channel messages, and SAR markers for testing'** + String get sampleDataDescription; + + /// Load sample data button + /// + /// In en, this message translates to: + /// **'Load Sample Data'** + String get loadSampleData; + + /// Clear all data button + /// + /// In en, this message translates to: + /// **'Clear All Data'** + String get clearAllData; + + /// Clear data confirmation dialog title + /// + /// In en, this message translates to: + /// **'Clear All Data'** + String get clearAllDataConfirmTitle; + + /// Clear data confirmation message + /// + /// In en, this message translates to: + /// **'This will clear all contacts and SAR markers. Are you sure?'** + String get clearAllDataConfirmMessage; + + /// Clear button label + /// + /// In en, this message translates to: + /// **'Clear'** + String get clear; + + /// Success message after loading sample data + /// + /// In en, this message translates to: + /// **'Loaded {teamCount} team members, {channelCount} channels, {sarCount} SAR markers, {messageCount} messages'** + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ); + + /// Error message when sample data fails to load + /// + /// In en, this message translates to: + /// **'Failed to load sample data: {error}'** + String failedToLoadSampleData(String error); + + /// Success message after clearing all data + /// + /// In en, this message translates to: + /// **'All data cleared'** + String get allDataCleared; + + /// Error message when background tracking fails to start + /// + /// In en, this message translates to: + /// **'Failed to start background tracking. Check permissions and BLE connection.'** + String get failedToStartBackgroundTracking; + + /// Success message for location broadcast + /// + /// In en, this message translates to: + /// **'Location broadcast: {latitude}, {longitude}'** + String locationBroadcast(String latitude, String longitude); + + /// Information about default PIN for pairing + /// + /// In en, this message translates to: + /// **'The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings.'** + String get defaultPinInfo; + + /// Empty state message when there are no messages + /// + /// In en, this message translates to: + /// **'No messages yet'** + String get noMessagesYet; + + /// Instruction to pull down to refresh messages + /// + /// In en, this message translates to: + /// **'Pull down to sync messages'** + String get pullDownToSync; + + /// Delete contact action label + /// + /// In en, this message translates to: + /// **'Delete Contact'** + String get deleteContact; + + /// Delete button label + /// + /// In en, this message translates to: + /// **'Delete'** + String get delete; + + /// Action to view contact location on map + /// + /// In en, this message translates to: + /// **'View on Map'** + String get viewOnMap; + + /// Refresh button label + /// + /// In en, this message translates to: + /// **'Refresh'** + String get refresh; + + /// Action to reset contact path for re-routing + /// + /// In en, this message translates to: + /// **'Reset Path (Re-route)'** + String get resetPath; + + /// Success message when a value is copied to clipboard + /// + /// In en, this message translates to: + /// **'{label} copied to clipboard'** + String copiedToClipboard(String label); + + /// Validation message for empty password field + /// + /// In en, this message translates to: + /// **'Please enter a password'** + String get pleaseEnterPassword; + + /// Error message when contact sync fails + /// + /// In en, this message translates to: + /// **'Failed to sync contacts: {error}'** + String failedToSyncContacts(String error); + + /// Success message after successful room login + /// + /// In en, this message translates to: + /// **'Logged in successfully! Waiting for room messages...'** + String get loggedInSuccessfully; + + /// Error message when room login fails + /// + /// In en, this message translates to: + /// **'Login failed - incorrect password'** + String get loginFailed; + + /// Status message during room login process + /// + /// In en, this message translates to: + /// **'Logging in to {roomName}...'** + String loggingIn(String roomName); + + /// Error message when login command fails to send + /// + /// In en, this message translates to: + /// **'Failed to send login: {error}'** + String failedToSendLogin(String error); + + /// Warning title for low GPS accuracy + /// + /// In en, this message translates to: + /// **'Low Location Accuracy'** + String get lowLocationAccuracy; + + /// Continue button label + /// + /// In en, this message translates to: + /// **'Continue'** + String get continue_; + + /// Action to send SAR marker + /// + /// In en, this message translates to: + /// **'Send SAR marker'** + String get sendSarMarker; + + /// Action to delete a map drawing + /// + /// In en, this message translates to: + /// **'Delete Drawing'** + String get deleteDrawing; + + /// Drawing tools section or menu title + /// + /// In en, this message translates to: + /// **'Drawing Tools'** + String get drawingTools; + + /// Map drawing mode: line + /// + /// In en, this message translates to: + /// **'Draw Line'** + String get drawLine; + + /// Description for line drawing mode + /// + /// In en, this message translates to: + /// **'Draw a freehand line on the map'** + String get drawLineDesc; + + /// Map drawing mode: rectangle + /// + /// In en, this message translates to: + /// **'Draw Rectangle'** + String get drawRectangle; + + /// Description for rectangle drawing mode + /// + /// In en, this message translates to: + /// **'Draw a rectangular area on the map'** + String get drawRectangleDesc; + + /// Map drawing mode: measure distance + /// + /// In en, this message translates to: + /// **'Measure Distance'** + String get measureDistance; + + /// Description for distance measurement mode + /// + /// In en, this message translates to: + /// **'Long press two points to measure'** + String get measureDistanceDesc; + + /// Tooltip to clear measurement + /// + /// In en, this message translates to: + /// **'Clear Measurement'** + String get clearMeasurement; + + /// Label showing measured distance + /// + /// In en, this message translates to: + /// **'Distance: {distance}'** + String distanceLabel(String distance); + + /// Instruction when first measurement point is set + /// + /// In en, this message translates to: + /// **'Long press for second point'** + String get longPressForSecondPoint; + + /// Instruction to start measurement + /// + /// In en, this message translates to: + /// **'Long press to set first point'** + String get longPressToStartMeasurement; + + /// Instruction to restart measurement after completion + /// + /// In en, this message translates to: + /// **'Long press to start new measurement'** + String get longPressToStartNewMeasurement; + + /// Action to share drawings to network + /// + /// In en, this message translates to: + /// **'Share Drawings'** + String get shareDrawings; + + /// Action to clear all local drawings + /// + /// In en, this message translates to: + /// **'Clear All Drawings'** + String get clearAllDrawings; + + /// Tooltip to complete drawing a line + /// + /// In en, this message translates to: + /// **'Complete Line'** + String get completeLine; + + /// Subtitle showing how many drawings will be broadcast + /// + /// In en, this message translates to: + /// **'Broadcast {count} drawing{plural} to team'** + String broadcastDrawingsToTeam(int count, String plural); + + /// Subtitle for remove all drawings action + /// + /// In en, this message translates to: + /// **'Remove all {count} drawing{plural}'** + String removeAllDrawings(int count, String plural); + + /// Confirmation dialog message for deleting all drawings + /// + /// In en, this message translates to: + /// **'Delete all {count} drawing{plural} from the map?'** + String deleteAllDrawingsConfirm(int count, String plural); + + /// Generic drawing label + /// + /// In en, this message translates to: + /// **'Drawing'** + String get drawing; + + /// Title for share drawings dialog + /// + /// In en, this message translates to: + /// **'Share {count} Drawing{plural}'** + String shareDrawingsCount(int count, String plural); + + /// Toggle to show/hide received drawings from other team members + /// + /// In en, this message translates to: + /// **'Show Received Drawings'** + String get showReceivedDrawings; + + /// Subtitle when received drawings are visible + /// + /// In en, this message translates to: + /// **'Showing all drawings'** + String get showingAllDrawings; + + /// Subtitle when received drawings are hidden + /// + /// In en, this message translates to: + /// **'Showing only your drawings'** + String get showingOnlyYourDrawings; + + /// Toggle to show/hide SAR markers on map + /// + /// In en, this message translates to: + /// **'Show SAR Markers'** + String get showSarMarkers; + + /// Subtitle when SAR markers are visible + /// + /// In en, this message translates to: + /// **'Showing SAR markers'** + String get showingSarMarkers; + + /// Subtitle when SAR markers are hidden + /// + /// In en, this message translates to: + /// **'Hiding SAR markers'** + String get hidingSarMarkers; + + /// Clear all button label + /// + /// In en, this message translates to: + /// **'Clear All'** + String get clearAll; + + /// Public channel option for sharing + /// + /// In en, this message translates to: + /// **'Public Channel'** + String get publicChannel; + + /// Description for public channel broadcast + /// + /// In en, this message translates to: + /// **'Broadcast to all nearby nodes (ephemeral)'** + String get broadcastToAll; + + /// Description for room storage permanence + /// + /// In en, this message translates to: + /// **'Stored permanently in room'** + String get storedPermanently; + + /// Error message when device is not connected for direct messaging + /// + /// In en, this message translates to: + /// **'Not connected to device'** + String get notConnectedToDevice; + + /// Placeholder text for message input field + /// + /// In en, this message translates to: + /// **'Type your message...'** + String get typeYourMessage; + + /// Subtitle for SAR marker sheet header + /// + /// In en, this message translates to: + /// **'Quick location marker'** + String get quickLocationMarker; + + /// Label for marker type selection section + /// + /// In en, this message translates to: + /// **'Marker Type'** + String get markerType; + + /// Label for destination selection section + /// + /// In en, this message translates to: + /// **'Send To'** + String get sendTo; + + /// Warning when no rooms or channels exist + /// + /// In en, this message translates to: + /// **'No destinations available.'** + String get noDestinationsAvailable; + + /// Placeholder for destination dropdown + /// + /// In en, this message translates to: + /// **'Select destination...'** + String get selectDestination; + + /// Information about ephemeral channel broadcasts + /// + /// In en, this message translates to: + /// **'Ephemeral: Broadcast over-the-air only. Not stored - nodes must be online.'** + String get ephemeralBroadcastInfo; + + /// Information about persistent room storage + /// + /// In en, this message translates to: + /// **'Persistent: Stored immutably in room. Synced automatically and preserved offline.'** + String get persistentRoomInfo; + + /// Label for location section + /// + /// In en, this message translates to: + /// **'Location'** + String get location; + + /// Badge showing location is from map tap + /// + /// In en, this message translates to: + /// **'From Map'** + String get fromMap; + + /// Loading message while fetching GPS location + /// + /// In en, this message translates to: + /// **'Getting location...'** + String get gettingLocation; + + /// Title for location error messages + /// + /// In en, this message translates to: + /// **'Location Error'** + String get locationError; + + /// Retry button label + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + + /// Tooltip for refresh location button + /// + /// In en, this message translates to: + /// **'Refresh location'** + String get refreshLocation; + + /// Display of GPS accuracy in meters + /// + /// In en, this message translates to: + /// **'Accuracy: ±{accuracy}m'** + String accuracyMeters(int accuracy); + + /// Label for optional notes field + /// + /// In en, this message translates to: + /// **'Notes (optional)'** + String get notesOptional; + + /// Placeholder for notes field + /// + /// In en, this message translates to: + /// **'Add additional information...'** + String get addAdditionalInformation; + + /// Warning dialog content for low GPS accuracy + /// + /// In en, this message translates to: + /// **'Location accuracy is ±{accuracy}m. This may not be accurate enough for SAR operations.\n\nContinue anyway?'** + String lowAccuracyWarning(int accuracy); + + /// Title for room login dialog + /// + /// In en, this message translates to: + /// **'Login to Room'** + String get loginToRoom; + + /// Information about room password + /// + /// In en, this message translates to: + /// **'Enter the password to access this room. The password will be saved for future use.'** + String get enterPasswordInfo; + + /// Password field label + /// + /// In en, this message translates to: + /// **'Password'** + String get password; + + /// Password field hint + /// + /// In en, this message translates to: + /// **'Enter room password'** + String get enterRoomPassword; + + /// Button text while logging in + /// + /// In en, this message translates to: + /// **'Logging in...'** + String get loggingInDots; + + /// Login button label + /// + /// In en, this message translates to: + /// **'Login'** + String get login; + + /// Error message when adding room fails + /// + /// In en, this message translates to: + /// **'Failed to add room to device: {error}\n\nThe room may not have advertised yet.\nTry waiting for the room to broadcast.'** + String failedToAddRoom(String error); + + /// Direct routing indicator + /// + /// In en, this message translates to: + /// **'Direct'** + String get direct; + + /// Flood routing indicator + /// + /// In en, this message translates to: + /// **'Flood'** + String get flood; + + /// Auto send mode - uses current path + /// + /// In en, this message translates to: + /// **'Auto Send'** + String get autoSend; + + /// Description for auto send mode + /// + /// In en, this message translates to: + /// **'Send via current path.'** + String get autoSendDescription; + + /// Send direct mode - zero-hop to contact + /// + /// In en, this message translates to: + /// **'Send Direct'** + String get sendDirect; + + /// Description for send direct mode + /// + /// In en, this message translates to: + /// **'Send directly to this contact.'** + String get sendDirectDescription; + + /// Send flood mode - via all repeaters + /// + /// In en, this message translates to: + /// **'Send Flood'** + String get sendFlood; + + /// Description for send flood mode + /// + /// In en, this message translates to: + /// **'Send via all repeaters.'** + String get sendFloodDescription; + + /// Logged in status badge + /// + /// In en, this message translates to: + /// **'Logged In'** + String get loggedIn; + + /// Message when GPS data is not available + /// + /// In en, this message translates to: + /// **'No GPS data'** + String get noGpsData; + + /// Distance label + /// + /// In en, this message translates to: + /// **'Distance'** + String get distance; + + /// Warning when direct ping times out + /// + /// In en, this message translates to: + /// **'Direct ping timeout - retrying {name} with flooding...'** + String directPingTimeout(String name); + + /// Error message when ping fails + /// + /// In en, this message translates to: + /// **'Ping failed to {name} - no response received'** + String pingFailed(String name); + + /// Confirmation message for deleting contact + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete \"{name}\"?\n\nThis will remove the contact from both the app and the companion radio device.'** + String deleteContactConfirmation(String name); + + /// Error message when contact removal fails + /// + /// In en, this message translates to: + /// **'Failed to remove contact: {error}'** + String failedToRemoveContact(String error); + + /// Contact type label + /// + /// In en, this message translates to: + /// **'Type'** + String get type; + + /// Public key label + /// + /// In en, this message translates to: + /// **'Public Key'** + String get publicKey; + + /// Last seen label + /// + /// In en, this message translates to: + /// **'Last Seen'** + String get lastSeen; + + /// Yes answer + /// + /// In en, this message translates to: + /// **'Yes'** + String get yes; + + /// No answer + /// + /// In en, this message translates to: + /// **'No'** + String get no; + + /// Permissions label + /// + /// In en, this message translates to: + /// **'Permissions'** + String get permissions; + + /// Telemetry section header + /// + /// In en, this message translates to: + /// **'Telemetry'** + String get telemetry; + + /// Voltage label + /// + /// In en, this message translates to: + /// **'Voltage'** + String get voltage; + + /// Battery label + /// + /// In en, this message translates to: + /// **'Battery'** + String get battery; + + /// Temperature label + /// + /// In en, this message translates to: + /// **'Temperature'** + String get temperature; + + /// Humidity label + /// + /// In en, this message translates to: + /// **'Humidity'** + String get humidity; + + /// Pressure label + /// + /// In en, this message translates to: + /// **'Pressure'** + String get pressure; + + /// GPS from telemetry label + /// + /// In en, this message translates to: + /// **'GPS (Telemetry)'** + String get gpsTelemetry; + + /// Button to re-login to room + /// + /// In en, this message translates to: + /// **'Re-Login to Room'** + String get reLoginToRoom; + + /// Compass heading label + /// + /// In en, this message translates to: + /// **'Heading'** + String get heading; + + /// Elevation/altitude label + /// + /// In en, this message translates to: + /// **'Elevation'** + String get elevation; + + /// GPS accuracy label + /// + /// In en, this message translates to: + /// **'Accuracy'** + String get accuracy; + + /// Bearing label in compass + /// + /// In en, this message translates to: + /// **'Bearing'** + String get bearing; + + /// Direction label in compass + /// + /// In en, this message translates to: + /// **'Direction'** + String get direction; + + /// Title for filter markers dialog + /// + /// In en, this message translates to: + /// **'Filter Markers'** + String get filterMarkers; + + /// Tooltip for filter button + /// + /// In en, this message translates to: + /// **'Filter markers'** + String get filterMarkersTooltip; + + /// Filter option for contacts + /// + /// In en, this message translates to: + /// **'Contacts'** + String get contactsFilter; + + /// Filter option for repeaters + /// + /// In en, this message translates to: + /// **'Repeaters'** + String get repeatersFilter; + + /// SAR markers section header + /// + /// In en, this message translates to: + /// **'SAR Markers'** + String get sarMarkers; + + /// Found person SAR marker type + /// + /// In en, this message translates to: + /// **'Found Person'** + String get foundPerson; + + /// Fire SAR marker type + /// + /// In en, this message translates to: + /// **'Fire'** + String get fire; + + /// Staging area SAR marker type + /// + /// In en, this message translates to: + /// **'Staging Area'** + String get stagingArea; + + /// Button to show all filters + /// + /// In en, this message translates to: + /// **'Show All'** + String get showAll; + + /// Message when GPS location is unavailable + /// + /// In en, this message translates to: + /// **'Location unavailable'** + String get locationUnavailable; + + /// Relative bearing direction - ahead + /// + /// In en, this message translates to: + /// **'ahead'** + String get ahead; + + /// Relative bearing direction - right + /// + /// In en, this message translates to: + /// **'{degrees}° right'** + String degreesRight(int degrees); + + /// Relative bearing direction - left + /// + /// In en, this message translates to: + /// **'{degrees}° left'** + String degreesLeft(int degrees); + + /// Latitude and longitude display format + /// + /// In en, this message translates to: + /// **'Lat: {latitude} Lon: {longitude}'** + String latLonFormat(String latitude, String longitude); + + /// Empty state message when there are no contacts + /// + /// In en, this message translates to: + /// **'No contacts yet'** + String get noContactsYet; + + /// Instruction to connect device to load contacts + /// + /// In en, this message translates to: + /// **'Connect to a device to load contacts'** + String get connectToDeviceToLoadContacts; + + /// Section header for team members (chat contacts) + /// + /// In en, this message translates to: + /// **'Team Members'** + String get teamMembers; + + /// Section header for repeater nodes + /// + /// In en, this message translates to: + /// **'Repeaters'** + String get repeaters; + + /// Section header for rooms + /// + /// In en, this message translates to: + /// **'Rooms'** + String get rooms; + + /// Section header for broadcast channels + /// + /// In en, this message translates to: + /// **'Channels'** + String get channels; + + /// Title for map layer selection dialog + /// + /// In en, this message translates to: + /// **'Select Map Layer'** + String get selectMapLayer; + + /// OpenStreetMap layer name + /// + /// In en, this message translates to: + /// **'OpenStreetMap'** + String get openStreetMap; + + /// OpenTopoMap layer name + /// + /// In en, this message translates to: + /// **'OpenTopoMap'** + String get openTopoMap; + + /// ESRI Satellite imagery layer name + /// + /// In en, this message translates to: + /// **'ESRI Satellite'** + String get esriSatellite; + + /// Google Hybrid layer name (satellite + labels) + /// + /// In en, this message translates to: + /// **'Google Hybrid'** + String get googleHybrid; + + /// Google Roadmap layer name (street map) + /// + /// In en, this message translates to: + /// **'Google Roadmap'** + String get googleRoadmap; + + /// Google Terrain layer name (topographic) + /// + /// In en, this message translates to: + /// **'Google Terrain'** + String get googleTerrain; + + /// Label when dragging a pin on map + /// + /// In en, this message translates to: + /// **'Drag to Position'** + String get dragToPosition; + + /// Label for creating SAR marker from pin + /// + /// In en, this message translates to: + /// **'Create SAR Marker'** + String get createSarMarker; + + /// Compass title in detailed compass dialog + /// + /// In en, this message translates to: + /// **'Compass'** + String get compass; + + /// Subtitle for compass dialog + /// + /// In en, this message translates to: + /// **'Navigation & Contacts'** + String get navigationAndContacts; + + /// Label for SAR alert badge on messages + /// + /// In en, this message translates to: + /// **'SAR ALERT'** + String get sarAlert; + + /// Success message when text is copied + /// + /// In en, this message translates to: + /// **'Text copied to clipboard'** + String get textCopiedToClipboard; + + /// Error when sender info is missing for reply + /// + /// In en, this message translates to: + /// **'Cannot reply: sender information missing'** + String get cannotReplySenderMissing; + + /// Error when contact not found for reply + /// + /// In en, this message translates to: + /// **'Cannot reply: contact not found'** + String get cannotReplyContactNotFound; + + /// Option to copy message text to clipboard + /// + /// In en, this message translates to: + /// **'Copy text'** + String get copyText; + + /// Option to save SAR message as a reusable template + /// + /// In en, this message translates to: + /// **'Save as Template'** + String get saveAsTemplate; + + /// Success message when SAR template is saved + /// + /// In en, this message translates to: + /// **'Template saved successfully'** + String get templateSaved; + + /// Error message when trying to save duplicate template + /// + /// In en, this message translates to: + /// **'Template with this emoji already exists'** + String get templateAlreadyExists; + + /// Dialog title for deleting a message + /// + /// In en, this message translates to: + /// **'Delete message'** + String get deleteMessage; + + /// Confirmation text for message deletion + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete this message?'** + String get deleteMessageConfirmation; + + /// Option to share SAR marker location + /// + /// In en, this message translates to: + /// **'Share location'** + String get shareLocation; + + /// Formatted text for sharing SAR marker location + /// + /// In en, this message translates to: + /// **'{markerInfo}\n\nCoordinates: {lat}, {lon}\n\nGoogle Maps: {url}'** + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ); + + /// Subject line when sharing SAR marker location + /// + /// In en, this message translates to: + /// **'SAR Location'** + String get sarLocationShare; + + /// Time indicator for very recent activity + /// + /// In en, this message translates to: + /// **'Just now'** + String get justNow; + + /// Time indicator for minutes ago + /// + /// In en, this message translates to: + /// **'{minutes}m ago'** + String minutesAgo(int minutes); + + /// Time indicator for hours ago + /// + /// In en, this message translates to: + /// **'{hours}h ago'** + String hoursAgo(int hours); + + /// Time indicator for days ago + /// + /// In en, this message translates to: + /// **'{days}d ago'** + String daysAgo(int days); + + /// Time indicator for seconds ago + /// + /// In en, this message translates to: + /// **'{seconds}s ago'** + String secondsAgo(int seconds); + + /// Delivery status: sending + /// + /// In en, this message translates to: + /// **'Sending...'** + String get sending; + + /// Delivery status: sent + /// + /// In en, this message translates to: + /// **'Sent'** + String get sent; + + /// Delivery status: delivered + /// + /// In en, this message translates to: + /// **'Delivered'** + String get delivered; + + /// Delivery status with round-trip time + /// + /// In en, this message translates to: + /// **'Delivered ({time}ms)'** + String deliveredWithTime(int time); + + /// Delivery status: failed + /// + /// In en, this message translates to: + /// **'Failed'** + String get failed; + + /// Delivery status for channel messages (no echoes yet) + /// + /// In en, this message translates to: + /// **'Broadcast'** + String get broadcast; + + /// Grouped message delivery count + /// + /// In en, this message translates to: + /// **'Delivered to {delivered}/{total} contacts'** + String deliveredToContacts(int delivered, int total); + + /// Status when all recipients received the message + /// + /// In en, this message translates to: + /// **'All delivered'** + String get allDelivered; + + /// Header for expandable recipient list + /// + /// In en, this message translates to: + /// **'Recipient Details'** + String get recipientDetails; + + /// Delivery status: pending/waiting + /// + /// In en, this message translates to: + /// **'Pending'** + String get pending; + + /// SAR marker type: found person + /// + /// In en, this message translates to: + /// **'Found Person'** + String get sarMarkerFoundPerson; + + /// SAR marker type: fire + /// + /// In en, this message translates to: + /// **'Fire Location'** + String get sarMarkerFire; + + /// SAR marker type: staging area + /// + /// In en, this message translates to: + /// **'Staging Area'** + String get sarMarkerStagingArea; + + /// SAR marker type: object + /// + /// In en, this message translates to: + /// **'Object Found'** + String get sarMarkerObject; + + /// Sender label in notifications + /// + /// In en, this message translates to: + /// **'From'** + String get from; + + /// Coordinates label + /// + /// In en, this message translates to: + /// **'Coordinates'** + String get coordinates; + + /// Notification action text + /// + /// In en, this message translates to: + /// **'Tap to view on map'** + String get tapToViewOnMap; + + /// Section title for radio settings + /// + /// In en, this message translates to: + /// **'Radio Settings'** + String get radioSettings; + + /// Label for radio frequency field + /// + /// In en, this message translates to: + /// **'Frequency (MHz)'** + String get frequencyMHz; + + /// Label for bandwidth dropdown + /// + /// In en, this message translates to: + /// **'Bandwidth'** + String get bandwidth; + + /// Label for spreading factor dropdown + /// + /// In en, this message translates to: + /// **'Spreading Factor'** + String get spreadingFactor; + + /// Label for coding rate dropdown + /// + /// In en, this message translates to: + /// **'Coding Rate'** + String get codingRate; + + /// Label for TX power field + /// + /// In en, this message translates to: + /// **'TX Power (dBm)'** + String get txPowerDbm; + + /// Helper text showing maximum TX power + /// + /// In en, this message translates to: + /// **'Max: {power} dBm'** + String maxPowerDbm(int power); + + /// Label for the current user in message bubbles + /// + /// In en, this message translates to: + /// **'You'** + String get you; + + /// Error message when export fails + /// + /// In en, this message translates to: + /// **'Export failed: {error}'** + String exportFailed(String error); + + /// Error message when import fails + /// + /// In en, this message translates to: + /// **'Import failed: {error}'** + String importFailed(String error); + + /// Unknown value label + /// + /// In en, this message translates to: + /// **'Unknown'** + String get unknown; + + /// Section header for online map layers + /// + /// In en, this message translates to: + /// **'Online Layers'** + String get onlineLayers; + + /// Location trail title + /// + /// In en, this message translates to: + /// **'Location Trail'** + String get locationTrail; + + /// Toggle to show/hide trail on map + /// + /// In en, this message translates to: + /// **'Show Trail on Map'** + String get showTrailOnMap; + + /// Trail visibility status - visible + /// + /// In en, this message translates to: + /// **'Trail is visible on the map'** + String get trailVisible; + + /// Trail visibility status - hidden but recording + /// + /// In en, this message translates to: + /// **'Trail is hidden (still recording)'** + String get trailHiddenRecording; + + /// Duration label + /// + /// In en, this message translates to: + /// **'Duration'** + String get duration; + + /// Trail points count label + /// + /// In en, this message translates to: + /// **'Points'** + String get points; + + /// Button to clear location trail + /// + /// In en, this message translates to: + /// **'Clear Trail'** + String get clearTrail; + + /// Confirmation dialog title + /// + /// In en, this message translates to: + /// **'Clear Trail?'** + String get clearTrailQuestion; + + /// Confirmation dialog message + /// + /// In en, this message translates to: + /// **'Are you sure you want to clear the current location trail? This action cannot be undone.'** + String get clearTrailConfirmation; + + /// Message when no trail exists + /// + /// In en, this message translates to: + /// **'No trail recorded yet'** + String get noTrailRecorded; + + /// Instructions to start trail recording + /// + /// In en, this message translates to: + /// **'Start location tracking to record your trail'** + String get startTrackingToRecord; + + /// Trail controls tooltip + /// + /// In en, this message translates to: + /// **'Trail Controls'** + String get trailControls; + + /// Device model label + /// + /// In en, this message translates to: + /// **'Model'** + String get model; + + /// Version label + /// + /// In en, this message translates to: + /// **'Version'** + String get version; + + /// Maximum contacts capacity label + /// + /// In en, this message translates to: + /// **'Max Contacts'** + String get maxContacts; + + /// Maximum channels capacity label + /// + /// In en, this message translates to: + /// **'Max Channels'** + String get maxChannels; + + /// Public information section header + /// + /// In en, this message translates to: + /// **'Public Info'** + String get publicInfo; + + /// Tooltip for use current location button + /// + /// In en, this message translates to: + /// **'Use current location'** + String get useCurrentLocation; + + /// Device type: none or unknown + /// + /// In en, this message translates to: + /// **'None/Unknown'** + String get noneUnknown; + + /// Device type: chat node + /// + /// In en, this message translates to: + /// **'Chat Node'** + String get chatNode; + + /// Device type: repeater + /// + /// In en, this message translates to: + /// **'Repeater'** + String get repeater; + + /// Device type: room or channel + /// + /// In en, this message translates to: + /// **'Room/Channel'** + String get roomChannel; + + /// Generic device type with number + /// + /// In en, this message translates to: + /// **'Type {number}'** + String typeNumber(int number); + + /// Generic error message for save failures + /// + /// In en, this message translates to: + /// **'Failed to save: {error}'** + String failedToSave(String error); + + /// Error message when getting location fails + /// + /// In en, this message translates to: + /// **'Failed to get location: {error}'** + String failedToGetLocation(String error); + + /// SAR templates menu title + /// + /// In en, this message translates to: + /// **'SAR Templates'** + String get sarTemplates; + + /// Button to add new SAR template + /// + /// In en, this message translates to: + /// **'Add Template'** + String get addTemplate; + + /// Dialog title for editing template + /// + /// In en, this message translates to: + /// **'Edit Template'** + String get editTemplate; + + /// Action to delete template + /// + /// In en, this message translates to: + /// **'Delete Template'** + String get deleteTemplate; + + /// Label for template name field + /// + /// In en, this message translates to: + /// **'Template Name'** + String get templateName; + + /// Hint text for template name + /// + /// In en, this message translates to: + /// **'e.g. Found Person'** + String get templateNameHint; + + /// Label for template emoji field + /// + /// In en, this message translates to: + /// **'Emoji'** + String get templateEmoji; + + /// Validation error when emoji field is empty + /// + /// In en, this message translates to: + /// **'Emoji is required'** + String get emojiRequired; + + /// Validation error when name field is empty + /// + /// In en, this message translates to: + /// **'Name is required'** + String get nameRequired; + + /// Label for template description field + /// + /// In en, this message translates to: + /// **'Description (Optional)'** + String get templateDescription; + + /// Hint text for template description + /// + /// In en, this message translates to: + /// **'Add additional context...'** + String get templateDescriptionHint; + + /// Label for template color picker + /// + /// In en, this message translates to: + /// **'Color'** + String get templateColor; + + /// Label for format preview + /// + /// In en, this message translates to: + /// **'Preview (SAR Message Format)'** + String get previewFormat; + + /// Button to import templates from clipboard + /// + /// In en, this message translates to: + /// **'Import'** + String get importFromClipboard; + + /// Button to export templates to clipboard + /// + /// In en, this message translates to: + /// **'Export'** + String get exportToClipboard; + + /// Confirmation message for template deletion + /// + /// In en, this message translates to: + /// **'Delete template \'{name}\'?'** + String deleteTemplateConfirmation(String name); + + /// Success message when template is added + /// + /// In en, this message translates to: + /// **'Template added'** + String get templateAdded; + + /// Success message when template is updated + /// + /// In en, this message translates to: + /// **'Template updated'** + String get templateUpdated; + + /// Success message when template is deleted + /// + /// In en, this message translates to: + /// **'Template deleted'** + String get templateDeleted; + + /// Success message after importing templates + /// + /// In en, this message translates to: + /// **'{count, plural, =0{No templates imported} =1{Imported 1 template} other{Imported {count} templates}}'** + String templatesImported(int count); + + /// Success message after exporting templates + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Exported 1 template to clipboard} other{Exported {count} templates to clipboard}}'** + String templatesExported(int count); + + /// Action to reset templates to defaults + /// + /// In en, this message translates to: + /// **'Reset to Defaults'** + String get resetToDefaults; + + /// Confirmation message for reset to defaults + /// + /// In en, this message translates to: + /// **'This will delete all custom templates and restore the 4 default templates. Continue?'** + String get resetToDefaultsConfirmation; + + /// Reset button label + /// + /// In en, this message translates to: + /// **'Reset'** + String get reset; + + /// Success message after reset + /// + /// In en, this message translates to: + /// **'Templates reset to defaults'** + String get resetComplete; + + /// Message when no templates exist + /// + /// In en, this message translates to: + /// **'No templates available'** + String get noTemplates; + + /// Helper text when no templates exist + /// + /// In en, this message translates to: + /// **'Tap + to create your first template'** + String get tapAddToCreate; + + /// OK button label + /// + /// In en, this message translates to: + /// **'OK'** + String get ok; + + /// Location permission label + /// + /// In en, this message translates to: + /// **'Location Permission'** + String get locationPermission; + + /// Loading state indicator + /// + /// In en, this message translates to: + /// **'Checking...'** + String get checking; + + /// Location permission status: granted always + /// + /// In en, this message translates to: + /// **'Granted (Always)'** + String get locationPermissionGrantedAlways; + + /// Location permission status: granted while in use + /// + /// In en, this message translates to: + /// **'Granted (While In Use)'** + String get locationPermissionGrantedWhileInUse; + + /// Location permission status: denied, user can request + /// + /// In en, this message translates to: + /// **'Denied - Tap to request'** + String get locationPermissionDeniedTapToRequest; + + /// Location permission status: permanently denied + /// + /// In en, this message translates to: + /// **'Permanently Denied - Open Settings'** + String get locationPermissionPermanentlyDeniedOpenSettings; + + /// Content for location permission dialog when permanently denied + /// + /// In en, this message translates to: + /// **'Location permission is permanently denied. Please enable it in your device settings to use GPS tracking and location sharing features.'** + String get locationPermissionDialogContent; + + /// Button to open device settings + /// + /// In en, this message translates to: + /// **'Open Settings'** + String get openSettings; + + /// Success message when location permission is granted + /// + /// In en, this message translates to: + /// **'Location permission granted!'** + String get locationPermissionGranted; + + /// Info message about location permission requirement + /// + /// In en, this message translates to: + /// **'Location permission is required for GPS tracking and location sharing.'** + String get locationPermissionRequiredForGps; + + /// Info message when permission is already granted + /// + /// In en, this message translates to: + /// **'Location permission is already granted.'** + String get locationPermissionAlreadyGranted; + + /// SAR Navy Blue theme name + /// + /// In en, this message translates to: + /// **'SAR Navy Blue'** + String get sarNavyBlue; + + /// Description for SAR Navy Blue theme + /// + /// In en, this message translates to: + /// **'Professional/Operations Mode'** + String get sarNavyBlueDescription; + + /// Title for recipient selector sheet + /// + /// In en, this message translates to: + /// **'Select Recipient'** + String get selectRecipient; + + /// Subtitle for public channel option + /// + /// In en, this message translates to: + /// **'Broadcast to all nearby'** + String get broadcastToAllNearby; + + /// Placeholder text for recipient search field + /// + /// In en, this message translates to: + /// **'Search recipients...'** + String get searchRecipients; + + /// Message when no contacts match search + /// + /// In en, this message translates to: + /// **'No contacts found'** + String get noContactsFound; + + /// Message when no rooms match search + /// + /// In en, this message translates to: + /// **'No rooms found'** + String get noRoomsFound; + + /// Message when no recipients exist (contacts, rooms, or channels) + /// + /// In en, this message translates to: + /// **'No recipients available'** + String get noRecipientsAvailable; + + /// Message when no channels match the search + /// + /// In en, this message translates to: + /// **'No channels found'** + String get noChannelsFound; + + /// Notification title for new message + /// + /// In en, this message translates to: + /// **'New message'** + String get newMessage; + + /// Channel label in notifications + /// + /// In en, this message translates to: + /// **'Channel'** + String get channel; + + /// Sample team member name + /// + /// In en, this message translates to: + /// **'Police Lead'** + String get samplePoliceLead; + + /// Sample team member name + /// + /// In en, this message translates to: + /// **'Drone Operator'** + String get sampleDroneOperator; + + /// Sample team member name + /// + /// In en, this message translates to: + /// **'Firefighter'** + String get sampleFirefighterAlpha; + + /// Sample team member name + /// + /// In en, this message translates to: + /// **'Medic'** + String get sampleMedicCharlie; + + /// Sample team member name + /// + /// In en, this message translates to: + /// **'Command'** + String get sampleCommandDelta; + + /// Sample team member name + /// + /// In en, this message translates to: + /// **'Fire Engine'** + String get sampleFireEngine; + + /// Sample team member name + /// + /// In en, this message translates to: + /// **'Air Support'** + String get sampleAirSupport; + + /// Sample team member name + /// + /// In en, this message translates to: + /// **'Base Coordinator'** + String get sampleBaseCoordinator; + + /// Emergency channel name + /// + /// In en, this message translates to: + /// **'Emergency'** + String get channelEmergency; + + /// Coordination channel name + /// + /// In en, this message translates to: + /// **'Coordination'** + String get channelCoordination; + + /// Updates channel name + /// + /// In en, this message translates to: + /// **'Updates'** + String get channelUpdates; + + /// Sample sender name + /// + /// In en, this message translates to: + /// **'Sample Team Member'** + String get sampleTeamMember; + + /// Sample sender name + /// + /// In en, this message translates to: + /// **'Sample Scout'** + String get sampleScout; + + /// Sample sender name + /// + /// In en, this message translates to: + /// **'Sample Base'** + String get sampleBase; + + /// Sample sender name + /// + /// In en, this message translates to: + /// **'Sample Searcher'** + String get sampleSearcher; + + /// Sample object note + /// + /// In en, this message translates to: + /// **' Backpack found - blue color'** + String get sampleObjectBackpack; + + /// Sample object note + /// + /// In en, this message translates to: + /// **' Vehicle abandoned - check for owner'** + String get sampleObjectVehicle; + + /// Sample object note + /// + /// In en, this message translates to: + /// **' Camping equipment discovered'** + String get sampleObjectCamping; + + /// Sample object note + /// + /// In en, this message translates to: + /// **' Trail marker found off-path'** + String get sampleObjectTrailMarker; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'All teams check in'** + String get sampleMsgAllTeamsCheckIn; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'Weather update: Clear skies, temp 18°C'** + String get sampleMsgWeatherUpdate; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'Base camp established at staging area'** + String get sampleMsgBaseCamp; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'Team moving to sector 2'** + String get sampleMsgTeamAlpha; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'Radio check - all stations respond'** + String get sampleMsgRadioCheck; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'Water supply available at checkpoint 3'** + String get sampleMsgWaterSupply; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'Team reporting: sector 1 clear'** + String get sampleMsgTeamBravo; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'ETA to rally point: 15 minutes'** + String get sampleMsgEtaRallyPoint; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'Supply drop confirmed for 14:00'** + String get sampleMsgSupplyDrop; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'Drone survey completed - no findings'** + String get sampleMsgDroneSurvey; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'Team requesting backup'** + String get sampleMsgTeamCharlie; + + /// Sample channel message + /// + /// In en, this message translates to: + /// **'All units: maintain radio discipline'** + String get sampleMsgRadioDiscipline; + + /// Sample emergency message + /// + /// In en, this message translates to: + /// **'URGENT: Medical assistance needed at sector 4'** + String get sampleMsgUrgentMedical; + + /// Sample emergency message note + /// + /// In en, this message translates to: + /// **' Adult male, conscious'** + String get sampleMsgAdultMale; + + /// Sample emergency message + /// + /// In en, this message translates to: + /// **'Fire spotted - coordinates incoming'** + String get sampleMsgFireSpotted; + + /// Sample emergency message note + /// + /// In en, this message translates to: + /// **' Spreading rapidly!'** + String get sampleMsgSpreadingRapidly; + + /// Sample emergency message + /// + /// In en, this message translates to: + /// **'PRIORITY: Need helicopter support'** + String get sampleMsgPriorityHelicopter; + + /// Sample emergency message + /// + /// In en, this message translates to: + /// **'Medical team en route to your location'** + String get sampleMsgMedicalTeamEnRoute; + + /// Sample emergency message + /// + /// In en, this message translates to: + /// **'Evac helicopter ETA 10 minutes'** + String get sampleMsgEvacHelicopter; + + /// Sample emergency message + /// + /// In en, this message translates to: + /// **'Emergency resolved - all clear'** + String get sampleMsgEmergencyResolved; + + /// Sample emergency message note + /// + /// In en, this message translates to: + /// **' Emergency staging area'** + String get sampleMsgEmergencyStagingArea; + + /// Sample emergency message + /// + /// In en, this message translates to: + /// **'Emergency services notified and responding'** + String get sampleMsgEmergencyServices; + + /// Sample team name + /// + /// In en, this message translates to: + /// **'Team Lead'** + String get sampleAlphaTeamLead; + + /// Sample team name + /// + /// In en, this message translates to: + /// **'Scout'** + String get sampleBravoScout; + + /// Sample team name + /// + /// In en, this message translates to: + /// **'Medic'** + String get sampleCharlieMedic; + + /// Sample team name + /// + /// In en, this message translates to: + /// **'Navigator'** + String get sampleDeltaNavigator; + + /// Sample team name + /// + /// In en, this message translates to: + /// **'Support'** + String get sampleEchoSupport; + + /// Sample team name + /// + /// In en, this message translates to: + /// **'Base Command'** + String get sampleBaseCommand; + + /// Sample team name + /// + /// In en, this message translates to: + /// **'Field Coordinator'** + String get sampleFieldCoordinator; + + /// Sample team name + /// + /// In en, this message translates to: + /// **'Medical Team'** + String get sampleMedicalTeam; + + /// Label for map drawing messages + /// + /// In en, this message translates to: + /// **'Map Drawing'** + String get mapDrawing; + + /// Option to navigate to drawing on map + /// + /// In en, this message translates to: + /// **'Navigate to Drawing'** + String get navigateToDrawing; + + /// Option to copy coordinates to clipboard + /// + /// In en, this message translates to: + /// **'Copy Coordinates'** + String get copyCoordinates; + + /// Option to hide drawing from map + /// + /// In en, this message translates to: + /// **'Hide from Map'** + String get hideFromMap; + + /// Label for line type drawings + /// + /// In en, this message translates to: + /// **'Line Drawing'** + String get lineDrawing; + + /// Label for rectangle type drawings + /// + /// In en, this message translates to: + /// **'Rectangle Drawing'** + String get rectangleDrawing; + + /// Label for manual coordinate input toggle + /// + /// In en, this message translates to: + /// **'Manual Coordinates'** + String get manualCoordinates; + + /// Description for manual coordinate input option + /// + /// In en, this message translates to: + /// **'Enter coordinates manually'** + String get enterCoordinatesManually; + + /// Label for latitude input field + /// + /// In en, this message translates to: + /// **'Latitude'** + String get latitudeLabel; + + /// Label for longitude input field + /// + /// In en, this message translates to: + /// **'Longitude'** + String get longitudeLabel; + + /// Example coordinate format hint + /// + /// In en, this message translates to: + /// **'Example: 46.0569, 14.5058'** + String get exampleCoordinates; + + /// Title for share single drawing dialog + /// + /// In en, this message translates to: + /// **'Share Drawing'** + String get shareDrawing; + + /// Subtitle for public channel sharing option + /// + /// In en, this message translates to: + /// **'Share with all nearby devices'** + String get shareWithAllNearbyDevices; + + /// Header for room sharing section + /// + /// In en, this message translates to: + /// **'Share to Room'** + String get shareToRoom; + + /// Subtitle for room sharing option + /// + /// In en, this message translates to: + /// **'Send to persistent room storage'** + String get sendToPersistentStorage; + + /// Confirmation message for deleting a single drawing + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete this drawing?'** + String get deleteDrawingConfirm; + + /// Success message after deleting a drawing + /// + /// In en, this message translates to: + /// **'Drawing deleted'** + String get drawingDeleted; + + /// Header showing count of user's drawings + /// + /// In en, this message translates to: + /// **'Your Drawings ({count})'** + String yourDrawingsCount(int count); + + /// Status label for shared drawings + /// + /// In en, this message translates to: + /// **'Shared'** + String get shared; + + /// Line drawing type label + /// + /// In en, this message translates to: + /// **'Line'** + String get line; + + /// Rectangle drawing type label + /// + /// In en, this message translates to: + /// **'Rectangle'** + String get rectangle; + + /// Title for update dialog when new version is available + /// + /// In en, this message translates to: + /// **'Update Available'** + String get updateAvailable; + + /// Label for current app version + /// + /// In en, this message translates to: + /// **'Current'** + String get currentVersion; + + /// Label for latest available app version + /// + /// In en, this message translates to: + /// **'Latest'** + String get latestVersion; + + /// Button to download app update + /// + /// In en, this message translates to: + /// **'Download'** + String get downloadUpdate; + + /// Button to dismiss update dialog + /// + /// In en, this message translates to: + /// **'Later'** + String get updateLater; + + /// Label for cadastral parcels WMS overlay layer + /// + /// In en, this message translates to: + /// **'Cadastral Parcels'** + String get cadastralParcels; + + /// Label for forest roads WMS overlay layer + /// + /// In en, this message translates to: + /// **'Forest Roads'** + String get forestRoads; + + /// Section header for WMS overlay layers in layer selector + /// + /// In en, this message translates to: + /// **'WMS Overlays'** + String get wmsOverlays; + + /// Label for hiking/mountain trails WMS overlay layer + /// + /// In en, this message translates to: + /// **'Hiking Trails'** + String get hikingTrails; + + /// Label for main roads WMS overlay layer + /// + /// In en, this message translates to: + /// **'Main Roads'** + String get mainRoads; + + /// Label for house numbers WMS overlay layer + /// + /// In en, this message translates to: + /// **'House Numbers'** + String get houseNumbers; + + /// Label for fire hazard risk zones WMS overlay layer + /// + /// In en, this message translates to: + /// **'Fire Hazard Zones'** + String get fireHazardZones; + + /// Label for historical forest fires WMS overlay layer + /// + /// In en, this message translates to: + /// **'Historical Fires'** + String get historicalFires; + + /// Label for firebreaks WMS overlay layer + /// + /// In en, this message translates to: + /// **'Firebreaks'** + String get firebreaks; + + /// Label for Kras fire zones WMS overlay layer + /// + /// In en, this message translates to: + /// **'Kras Fire Zones'** + String get krasFireZones; + + /// Label for geographic place names WMS overlay layer + /// + /// In en, this message translates to: + /// **'Place Names'** + String get placeNames; + + /// Label for municipality borders WMS overlay layer + /// + /// In en, this message translates to: + /// **'Municipality Borders'** + String get municipalityBorders; + + /// Label for DTK25 topographic base map layer + /// + /// In en, this message translates to: + /// **'Topographic Map 1:25000'** + String get topographicMap; + + /// Header for recent messages overlay on map in fullscreen mode + /// + /// In en, this message translates to: + /// **'Recent Messages'** + String get recentMessages; + + /// Button to add a new channel + /// + /// In en, this message translates to: + /// **'Add Channel'** + String get addChannel; + + /// Label for channel name field + /// + /// In en, this message translates to: + /// **'Channel Name'** + String get channelName; + + /// Hint for channel name field + /// + /// In en, this message translates to: + /// **'e.g., Rescue Team Alpha'** + String get channelNameHint; + + /// Label for channel secret field + /// + /// In en, this message translates to: + /// **'Channel Secret'** + String get channelSecret; + + /// Hint for channel secret field + /// + /// In en, this message translates to: + /// **'Shared password for this channel'** + String get channelSecretHint; + + /// Help text explaining channel secret + /// + /// In en, this message translates to: + /// **'This secret must be shared with all team members who need access to this channel'** + String get channelSecretHelp; + + /// Information banner explaining hash and private channel types + /// + /// In en, this message translates to: + /// **'Hash channels (#team): Secret auto-generated from name. Same name = same channel across devices.\n\nPrivate channels: Use explicit secret. Only those with the secret can join.'** + String get channelTypesInfo; + + /// Help text for hash channels (# prefix) + /// + /// In en, this message translates to: + /// **'Hash channel: Secret will be auto-generated from the channel name. Anyone using the same name will join the same channel.'** + String get hashChannelInfo; + + /// Validation error for empty channel name + /// + /// In en, this message translates to: + /// **'Channel name is required'** + String get channelNameRequired; + + /// Validation error for channel name too long + /// + /// In en, this message translates to: + /// **'Channel name must be 31 characters or less'** + String get channelNameTooLong; + + /// Validation error for empty channel secret + /// + /// In en, this message translates to: + /// **'Channel secret is required'** + String get channelSecretRequired; + + /// Validation error for channel secret too long + /// + /// In en, this message translates to: + /// **'Channel secret must be 32 characters or less'** + String get channelSecretTooLong; + + /// Validation error for non-ASCII characters + /// + /// In en, this message translates to: + /// **'Only ASCII characters are allowed'** + String get invalidAsciiCharacters; + + /// Success message after creating channel + /// + /// In en, this message translates to: + /// **'Channel created successfully'** + String get channelCreatedSuccessfully; + + /// Error message when channel creation fails + /// + /// In en, this message translates to: + /// **'Failed to create channel: {error}'** + String channelCreationFailed(String error); + + /// Delete channel button/menu item + /// + /// In en, this message translates to: + /// **'Delete Channel'** + String get deleteChannel; + + /// Confirmation dialog when deleting a channel + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete channel \"{channelName}\"? This action cannot be undone.'** + String deleteChannelConfirmation(String channelName); + + /// Success message after deleting channel + /// + /// In en, this message translates to: + /// **'Channel deleted successfully'** + String get channelDeletedSuccessfully; + + /// Error message when channel deletion fails + /// + /// In en, this message translates to: + /// **'Failed to delete channel: {error}'** + String channelDeletionFailed(String error); + + /// Button text for creating a channel + /// + /// In en, this message translates to: + /// **'Create Channel'** + String get createChannel; + + /// Wizard back button text + /// + /// In en, this message translates to: + /// **'Back'** + String get wizardBack; + + /// Wizard skip button text + /// + /// In en, this message translates to: + /// **'Skip'** + String get wizardSkip; + + /// Wizard next button text + /// + /// In en, this message translates to: + /// **'Next'** + String get wizardNext; + + /// Wizard final button text to complete onboarding + /// + /// In en, this message translates to: + /// **'Get Started'** + String get wizardGetStarted; + + /// Welcome wizard first page title + /// + /// In en, this message translates to: + /// **'Welcome to MeshCore SAR'** + String get wizardWelcomeTitle; + + /// Settings option to re-show welcome wizard + /// + /// In en, this message translates to: + /// **'View Welcome Tutorial'** + String get viewWelcomeTutorial; + + /// Destination option to send SAR marker to all team contacts + /// + /// In en, this message translates to: + /// **'All Team Contacts'** + String get allTeamContacts; + + /// Information about sending to all contacts + /// + /// In en, this message translates to: + /// **'Direct messages with ACKs. Sent to {count} team members.'** + String directMessagesInfo(int count); + + /// Success message after sending SAR marker to all contacts + /// + /// In en, this message translates to: + /// **'SAR marker sent to {count} contacts'** + String sarMarkerSentToContacts(int count); + + /// Message when there are no chat contacts to send to + /// + /// In en, this message translates to: + /// **'No team contacts available'** + String get noContactsAvailable; + + /// No description provided for @reply. + /// + /// In en, this message translates to: + /// **'Reply'** + String get reply; + + /// No description provided for @technicalDetails. + /// + /// In en, this message translates to: + /// **'Details'** + String get technicalDetails; + + /// No description provided for @messageTechnicalDetails. + /// + /// In en, this message translates to: + /// **'Message details'** + String get messageTechnicalDetails; + + /// No description provided for @linkQuality. + /// + /// In en, this message translates to: + /// **'Link quality'** + String get linkQuality; + + /// No description provided for @delivery. + /// + /// In en, this message translates to: + /// **'Delivery'** + String get delivery; + + /// No description provided for @status. + /// + /// In en, this message translates to: + /// **'Status'** + String get status; + + /// No description provided for @expectedAckTag. + /// + /// In en, this message translates to: + /// **'Expected ACK tag'** + String get expectedAckTag; + + /// No description provided for @roundTrip. + /// + /// In en, this message translates to: + /// **'Round-trip'** + String get roundTrip; + + /// No description provided for @retryAttempt. + /// + /// In en, this message translates to: + /// **'Retry attempt'** + String get retryAttempt; + + /// No description provided for @floodFallback. + /// + /// In en, this message translates to: + /// **'Flood fallback'** + String get floodFallback; + + /// No description provided for @identity. + /// + /// In en, this message translates to: + /// **'Identity'** + String get identity; + + /// No description provided for @messageId. + /// + /// In en, this message translates to: + /// **'Message ID'** + String get messageId; + + /// No description provided for @sender. + /// + /// In en, this message translates to: + /// **'Sender'** + String get sender; + + /// No description provided for @senderKey. + /// + /// In en, this message translates to: + /// **'Sender key'** + String get senderKey; + + /// No description provided for @recipient. + /// + /// In en, this message translates to: + /// **'Recipient'** + String get recipient; + + /// No description provided for @recipientKey. + /// + /// In en, this message translates to: + /// **'Recipient key'** + String get recipientKey; + + /// No description provided for @voice. + /// + /// In en, this message translates to: + /// **'Voice'** + String get voice; + + /// No description provided for @voiceId. + /// + /// In en, this message translates to: + /// **'Voice ID'** + String get voiceId; + + /// No description provided for @envelope. + /// + /// In en, this message translates to: + /// **'Envelope'** + String get envelope; + + /// No description provided for @sessionProgress. + /// + /// In en, this message translates to: + /// **'Session progress'** + String get sessionProgress; + + /// No description provided for @complete. + /// + /// In en, this message translates to: + /// **'Complete'** + String get complete; + + /// No description provided for @rawDump. + /// + /// In en, this message translates to: + /// **'Raw dump'** + String get rawDump; + + /// No description provided for @cannotRetryMissingRecipient. + /// + /// In en, this message translates to: + /// **'Cannot retry: recipient information missing'** + String get cannotRetryMissingRecipient; + + /// No description provided for @voiceUnavailable. + /// + /// In en, this message translates to: + /// **'Voice unavailable right now'** + String get voiceUnavailable; + + /// No description provided for @requestingVoice. + /// + /// In en, this message translates to: + /// **'Requesting voice'** + String get requestingVoice; + + /// Generic device label + /// + /// In en, this message translates to: + /// **'device'** + String get device; + + /// Generic change action + /// + /// In en, this message translates to: + /// **'Change'** + String get change; + + /// Updated onboarding overview description + /// + /// In en, this message translates to: + /// **'This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.'** + String get wizardOverviewDescription; + + /// Updated onboarding overview feature 1 + /// + /// In en, this message translates to: + /// **'Send direct, room, and channel messages from the main Messages tab.'** + String get wizardOverviewFeature1; + + /// Updated onboarding overview feature 2 + /// + /// In en, this message translates to: + /// **'Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.'** + String get wizardOverviewFeature2; + + /// Updated onboarding overview feature 3 + /// + /// In en, this message translates to: + /// **'Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.'** + String get wizardOverviewFeature3; + + /// Updated onboarding messaging page title + /// + /// In en, this message translates to: + /// **'Messaging and Field Reports'** + String get wizardMessagingTitle; + + /// Updated onboarding messaging page description + /// + /// In en, this message translates to: + /// **'Messages are more than plain text here. The app already supports several operational payloads and transfer workflows.'** + String get wizardMessagingDescription; + + /// Updated onboarding messaging feature 1 + /// + /// In en, this message translates to: + /// **'Send direct messages, room posts, and channel traffic from one composer.'** + String get wizardMessagingFeature1; + + /// Updated onboarding messaging feature 2 + /// + /// In en, this message translates to: + /// **'Create SAR updates and reusable SAR templates for common field reports.'** + String get wizardMessagingFeature2; + + /// Updated onboarding messaging feature 3 + /// + /// In en, this message translates to: + /// **'Transfer voice sessions and images, with progress and airtime estimates in the UI.'** + String get wizardMessagingFeature3; + + /// Onboarding connect device page title + /// + /// In en, this message translates to: + /// **'Connect device'** + String get wizardConnectDeviceTitle; + + /// Onboarding connect device page description + /// + /// In en, this message translates to: + /// **'Connect your MeshCore radio, choose a name, and apply a radio preset before continuing.'** + String get wizardConnectDeviceDescription; + + /// Onboarding setup badge label + /// + /// In en, this message translates to: + /// **'Setup'** + String get wizardSetupBadge; + + /// Onboarding overview badge label + /// + /// In en, this message translates to: + /// **'Overview'** + String get wizardOverviewBadge; + + /// Onboarding connection status when connected + /// + /// In en, this message translates to: + /// **'Connected to {deviceName}'** + String wizardConnectedToDevice(String deviceName); + + /// Onboarding connection status when disconnected + /// + /// In en, this message translates to: + /// **'No device connected yet'** + String get wizardNoDeviceConnected; + + /// Onboarding skip current setup step action + /// + /// In en, this message translates to: + /// **'Skip for now'** + String get wizardSkipForNow; + + /// Onboarding device name field label + /// + /// In en, this message translates to: + /// **'Device name'** + String get wizardDeviceNameLabel; + + /// Onboarding device name field helper text + /// + /// In en, this message translates to: + /// **'This name is advertised to other MeshCore users.'** + String get wizardDeviceNameHelp; + + /// Onboarding config region field label + /// + /// In en, this message translates to: + /// **'Config region'** + String get wizardConfigRegionLabel; + + /// Onboarding config region helper text + /// + /// In en, this message translates to: + /// **'Uses the full official MeshCore preset list. Default is EU/UK (Narrow).'** + String get wizardConfigRegionHelp; + + /// Onboarding preset note 1 + /// + /// In en, this message translates to: + /// **'Make sure the selected preset matches your local radio regulations.'** + String get wizardPresetNote1; + + /// Onboarding preset note 2 + /// + /// In en, this message translates to: + /// **'The list matches the official MeshCore config tool preset feed.'** + String get wizardPresetNote2; + + /// Onboarding preset note 3 + /// + /// In en, this message translates to: + /// **'EU/UK (Narrow) stays selected by default for onboarding.'** + String get wizardPresetNote3; + + /// Onboarding saving button label + /// + /// In en, this message translates to: + /// **'Saving...'** + String get wizardSaving; + + /// Onboarding save and continue button label + /// + /// In en, this message translates to: + /// **'Save and continue'** + String get wizardSaveAndContinue; + + /// Onboarding validation when device name is missing + /// + /// In en, this message translates to: + /// **'Enter a device name before continuing.'** + String get wizardEnterDeviceName; + + /// Onboarding success message after saving device setup + /// + /// In en, this message translates to: + /// **'Saved {deviceName} with {presetName}.'** + String wizardDeviceSetupSaved(String deviceName, String presetName); + + /// Updated onboarding network page title + /// + /// In en, this message translates to: + /// **'Contacts, Rooms, and Repeaters'** + String get wizardNetworkTitle; + + /// Updated onboarding network page description + /// + /// In en, this message translates to: + /// **'The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.'** + String get wizardNetworkDescription; + + /// Updated onboarding network feature 1 + /// + /// In en, this message translates to: + /// **'Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.'** + String get wizardNetworkFeature1; + + /// Updated onboarding network feature 2 + /// + /// In en, this message translates to: + /// **'Use smart ping, room login, learned paths, and route reset tools when connectivity gets messy.'** + String get wizardNetworkFeature2; + + /// Updated onboarding network feature 3 + /// + /// In en, this message translates to: + /// **'Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.'** + String get wizardNetworkFeature3; + + /// Updated onboarding map page title + /// + /// In en, this message translates to: + /// **'Map, Trails, and Shared Geometry'** + String get wizardMapOpsTitle; + + /// Updated onboarding map page description + /// + /// In en, this message translates to: + /// **'The app map is tied directly into messaging, tracking, and SAR overlays instead of being a separate viewer.'** + String get wizardMapOpsDescription; + + /// Updated onboarding map feature 1 + /// + /// In en, this message translates to: + /// **'Track your own position, teammate locations, and share live location to a private channel from the channel menu.'** + String get wizardMapOpsFeature1; + + /// Updated onboarding map feature 2 + /// + /// In en, this message translates to: + /// **'Open drawings from messages, preview them inline, and remove them from the map when needed.'** + String get wizardMapOpsFeature2; + + /// Updated onboarding map feature 3 + /// + /// In en, this message translates to: + /// **'Use repeater map views and shared overlays to understand network reach in the field.'** + String get wizardMapOpsFeature3; + + /// Updated onboarding tools page title + /// + /// In en, this message translates to: + /// **'Tools Beyond Messaging'** + String get wizardToolsTitle; + + /// Updated onboarding tools page description + /// + /// In en, this message translates to: + /// **'There is more here than the four main tabs. The app also includes configuration, diagnostics, and optional sensor workflows.'** + String get wizardToolsDescription; + + /// Updated onboarding tools feature 1 + /// + /// In en, this message translates to: + /// **'Open device config to change radio settings, telemetry, TX power, and companion details.'** + String get wizardToolsFeature1; + + /// Updated onboarding tools feature 2 + /// + /// In en, this message translates to: + /// **'Enable the Sensors tab when you want watched sensor dashboards and quick refresh actions.'** + String get wizardToolsFeature2; + + /// Updated onboarding tools feature 3 + /// + /// In en, this message translates to: + /// **'Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'** + String get wizardToolsFeature3; + + /// Title for the prompt shown after a successful device connection + /// + /// In en, this message translates to: + /// **'Discover repeaters now?'** + String get postConnectDiscoveryTitle; + + /// Body text for the prompt shown after a successful device connection + /// + /// In en, this message translates to: + /// **'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'** + String get postConnectDiscoveryDescription; + + /// Contact actions label when already in sensors + /// + /// In en, this message translates to: + /// **'In Sensors'** + String get contactInSensors; + + /// Contact actions label to add to sensors + /// + /// In en, this message translates to: + /// **'Add to Sensors'** + String get contactAddToSensors; + + /// Contact actions label to set route path + /// + /// In en, this message translates to: + /// **'Set path'** + String get contactSetPath; + + /// Snackbar after adding contact to sensors + /// + /// In en, this message translates to: + /// **'{contactName} added to Sensors'** + String contactAddedToSensors(String contactName); + + /// Error when clearing a contact route fails + /// + /// In en, this message translates to: + /// **'Failed to clear route: {error}'** + String contactFailedToClearRoute(String error); + + /// Snackbar after route is cleared + /// + /// In en, this message translates to: + /// **'Route cleared'** + String get contactRouteCleared; + + /// Snackbar after route is set + /// + /// In en, this message translates to: + /// **'Route set: {route}'** + String contactRouteSet(String route); + + /// Error when setting a contact route fails + /// + /// In en, this message translates to: + /// **'Failed to set route: {error}'** + String contactFailedToSetRoute(String error); + + /// Received signal strength indicator + /// + /// In en, this message translates to: + /// **'RSSI'** + String get rssi; + + /// Signal-to-noise ratio + /// + /// In en, this message translates to: + /// **'SNR'** + String get snr; + + /// No description provided for @ackTimeout. + /// + /// In en, this message translates to: + /// **'ACK timeout'** + String get ackTimeout; + + /// No description provided for @opcode. + /// + /// In en, this message translates to: + /// **'Opcode'** + String get opcode; + + /// No description provided for @payload. + /// + /// In en, this message translates to: + /// **'Payload'** + String get payload; + + /// No description provided for @hops. + /// + /// In en, this message translates to: + /// **'Hops'** + String get hops; + + /// No description provided for @hashSize. + /// + /// In en, this message translates to: + /// **'Hash size'** + String get hashSize; + + /// No description provided for @pathBytes. + /// + /// In en, this message translates to: + /// **'Path bytes'** + String get pathBytes; + + /// No description provided for @selectedPath. + /// + /// In en, this message translates to: + /// **'Selected path'** + String get selectedPath; + + /// No description provided for @estimatedTx. + /// + /// In en, this message translates to: + /// **'Estimated tx'** + String get estimatedTx; + + /// No description provided for @senderToReceipt. + /// + /// In en, this message translates to: + /// **'Sender to receipt'** + String get senderToReceipt; + + /// No description provided for @receivedCopies. + /// + /// In en, this message translates to: + /// **'Received copies'** + String get receivedCopies; + + /// No description provided for @retryCause. + /// + /// In en, this message translates to: + /// **'Retry cause'** + String get retryCause; + + /// No description provided for @retryMode. + /// + /// In en, this message translates to: + /// **'Retry mode'** + String get retryMode; + + /// No description provided for @retryResult. + /// + /// In en, this message translates to: + /// **'Retry result'** + String get retryResult; + + /// No description provided for @lastRetry. + /// + /// In en, this message translates to: + /// **'Last retry'** + String get lastRetry; + + /// No description provided for @rxPackets. + /// + /// In en, this message translates to: + /// **'RX packets'** + String get rxPackets; + + /// No description provided for @mesh. + /// + /// In en, this message translates to: + /// **'Mesh'** + String get mesh; + + /// No description provided for @rate. + /// + /// In en, this message translates to: + /// **'Rate'** + String get rate; + + /// No description provided for @window. + /// + /// In en, this message translates to: + /// **'Window'** + String get window; + + /// No description provided for @posttxDelay. + /// + /// In en, this message translates to: + /// **'Post-tx delay'** + String get posttxDelay; + + /// No description provided for @bandpass. + /// + /// In en, this message translates to: + /// **'Band-pass'** + String get bandpass; + + /// No description provided for @bandpassFilterVoice. + /// + /// In en, this message translates to: + /// **'Band-pass filter voice'** + String get bandpassFilterVoice; + + /// No description provided for @active. + /// + /// In en, this message translates to: + /// **'Active'** + String get active; + + /// No description provided for @addContact. + /// + /// In en, this message translates to: + /// **'Add Contact'** + String get addContact; + + /// No description provided for @all. + /// + /// In en, this message translates to: + /// **'All'** + String get all; + + /// No description provided for @clearAllLabel. + /// + /// In en, this message translates to: + /// **'Clear all'** + String get clearAllLabel; + + /// No description provided for @clearFilters. + /// + /// In en, this message translates to: + /// **'Clear filters'** + String get clearFilters; + + /// No description provided for @clearRoute. + /// + /// In en, this message translates to: + /// **'Clear Route'** + String get clearRoute; + + /// No description provided for @clearMessages. + /// + /// In en, this message translates to: + /// **'Clear Messages'** + String get clearMessages; + + /// No description provided for @clearScale. + /// + /// In en, this message translates to: + /// **'Clear scale'** + String get clearScale; + + /// No description provided for @clearDiscoveries. + /// + /// In en, this message translates to: + /// **'Clear discoveries'** + String get clearDiscoveries; + + /// No description provided for @clearOnlineTraceDatabase. + /// + /// In en, this message translates to: + /// **'Clear online trace database'** + String get clearOnlineTraceDatabase; + + /// No description provided for @clearAllChannels. + /// + /// In en, this message translates to: + /// **'Clear all channels'** + String get clearAllChannels; + + /// No description provided for @clearAllContacts. + /// + /// In en, this message translates to: + /// **'Clear all contacts'** + String get clearAllContacts; + + /// No description provided for @clearChannels. + /// + /// In en, this message translates to: + /// **'Clear channels'** + String get clearChannels; + + /// No description provided for @clearContacts. + /// + /// In en, this message translates to: + /// **'Clear contacts'** + String get clearContacts; + + /// No description provided for @clearPathOnMaxRetry. + /// + /// In en, this message translates to: + /// **'Clear path on max retry'** + String get clearPathOnMaxRetry; + + /// No description provided for @create. + /// + /// In en, this message translates to: + /// **'Create'** + String get create; + + /// No description provided for @custom. + /// + /// In en, this message translates to: + /// **'Custom'** + String get custom; + + /// No description provided for @defaultValue. + /// + /// In en, this message translates to: + /// **'Default'** + String get defaultValue; + + /// No description provided for @duplicate. + /// + /// In en, this message translates to: + /// **'Duplicate'** + String get duplicate; + + /// No description provided for @editName. + /// + /// In en, this message translates to: + /// **'Edit name'** + String get editName; + + /// No description provided for @open. + /// + /// In en, this message translates to: + /// **'Open'** + String get open; + + /// No description provided for @paste. + /// + /// In en, this message translates to: + /// **'Paste'** + String get paste; + + /// No description provided for @preview. + /// + /// In en, this message translates to: + /// **'Preview'** + String get preview; + + /// No description provided for @remove. + /// + /// In en, this message translates to: + /// **'Remove'** + String get remove; + + /// No description provided for @rename. + /// + /// In en, this message translates to: + /// **'Rename'** + String get rename; + + /// No description provided for @resolveAll. + /// + /// In en, this message translates to: + /// **'Resolve all'** + String get resolveAll; + + /// No description provided for @send. + /// + /// In en, this message translates to: + /// **'Send'** + String get send; + + /// No description provided for @sendAnyway. + /// + /// In en, this message translates to: + /// **'Send anyway'** + String get sendAnyway; + + /// No description provided for @share. + /// + /// In en, this message translates to: + /// **'Share'** + String get share; + + /// No description provided for @trace. + /// + /// In en, this message translates to: + /// **'Trace'** + String get trace; + + /// No description provided for @discovery. + /// + /// In en, this message translates to: + /// **'Discovery'** + String get discovery; + + /// No description provided for @discoverRepeaters. + /// + /// In en, this message translates to: + /// **'Discover repeaters'** + String get discoverRepeaters; + + /// No description provided for @discoverSensors. + /// + /// In en, this message translates to: + /// **'Discover sensors'** + String get discoverSensors; + + /// No description provided for @repeaterDiscoverySent. + /// + /// In en, this message translates to: + /// **'Repeater discovery sent'** + String get repeaterDiscoverySent; + + /// No description provided for @sensorDiscoverySent. + /// + /// In en, this message translates to: + /// **'Sensor discovery sent'** + String get sensorDiscoverySent; + + /// No description provided for @clearedPendingDiscoveries. + /// + /// In en, this message translates to: + /// **'Cleared pending discoveries.'** + String get clearedPendingDiscoveries; + + /// No description provided for @autoDiscovery. + /// + /// In en, this message translates to: + /// **'Auto discovery'** + String get autoDiscovery; + + /// No description provided for @enableAutomaticAdding. + /// + /// In en, this message translates to: + /// **'Enable automatic adding'** + String get enableAutomaticAdding; + + /// No description provided for @autoaddRepeaters. + /// + /// In en, this message translates to: + /// **'Auto-add repeaters'** + String get autoaddRepeaters; + + /// No description provided for @autoaddRoomServers. + /// + /// In en, this message translates to: + /// **'Auto-add room servers'** + String get autoaddRoomServers; + + /// No description provided for @autoaddSensors. + /// + /// In en, this message translates to: + /// **'Auto-add sensors'** + String get autoaddSensors; + + /// No description provided for @autoaddUsers. + /// + /// In en, this message translates to: + /// **'Auto-add users'** + String get autoaddUsers; + + /// No description provided for @overwriteOldestWhenFull. + /// + /// In en, this message translates to: + /// **'Overwrite oldest when full'** + String get overwriteOldestWhenFull; + + /// No description provided for @storage. + /// + /// In en, this message translates to: + /// **'Storage'** + String get storage; + + /// No description provided for @dangerZone. + /// + /// In en, this message translates to: + /// **'Danger zone'** + String get dangerZone; + + /// No description provided for @profiles. + /// + /// In en, this message translates to: + /// **'Profiles'** + String get profiles; + + /// No description provided for @favourites. + /// + /// In en, this message translates to: + /// **'Favourites'** + String get favourites; + + /// No description provided for @sensors. + /// + /// In en, this message translates to: + /// **'Sensors'** + String get sensors; + + /// No description provided for @others. + /// + /// In en, this message translates to: + /// **'Others'** + String get others; + + /// No description provided for @gpsModule. + /// + /// In en, this message translates to: + /// **'GPS Module'** + String get gpsModule; + + /// No description provided for @liveTraffic. + /// + /// In en, this message translates to: + /// **'Live Traffic'** + String get liveTraffic; + + /// No description provided for @repeatersMap. + /// + /// In en, this message translates to: + /// **'Repeaters Map'** + String get repeatersMap; + + /// No description provided for @blePacketLogs. + /// + /// In en, this message translates to: + /// **'BLE Packet Logs'** + String get blePacketLogs; + + /// No description provided for @onlineTraceDatabase. + /// + /// In en, this message translates to: + /// **'Online trace database'** + String get onlineTraceDatabase; + + /// No description provided for @routePathByteSize. + /// + /// In en, this message translates to: + /// **'Route path byte size'** + String get routePathByteSize; + + /// No description provided for @messageNotifications. + /// + /// In en, this message translates to: + /// **'Message notifications'** + String get messageNotifications; + + /// No description provided for @sarAlerts. + /// + /// In en, this message translates to: + /// **'SAR alerts'** + String get sarAlerts; + + /// No description provided for @discoveryNotifications. + /// + /// In en, this message translates to: + /// **'Discovery notifications'** + String get discoveryNotifications; + + /// No description provided for @updateNotifications. + /// + /// In en, this message translates to: + /// **'Update notifications'** + String get updateNotifications; + + /// No description provided for @muteWhileAppIsOpen. + /// + /// In en, this message translates to: + /// **'Mute while app is open'** + String get muteWhileAppIsOpen; + + /// No description provided for @disableContacts. + /// + /// In en, this message translates to: + /// **'Disable Contacts'** + String get disableContacts; + + /// No description provided for @enableSensorsTab. + /// + /// In en, this message translates to: + /// **'Enable Sensors tab'** + String get enableSensorsTab; + + /// No description provided for @enableProfiles. + /// + /// In en, this message translates to: + /// **'Enable Profiles'** + String get enableProfiles; + + /// No description provided for @nearestRepeaterFallback. + /// + /// In en, this message translates to: + /// **'Nearest repeater fallback'** + String get nearestRepeaterFallback; + + /// No description provided for @deleteAllStoredMessageHistory. + /// + /// In en, this message translates to: + /// **'Delete all stored message history'** + String get deleteAllStoredMessageHistory; + + /// No description provided for @messageFontSize. + /// + /// In en, this message translates to: + /// **'Message font size'** + String get messageFontSize; + + /// No description provided for @rotateMapWithHeading. + /// + /// In en, this message translates to: + /// **'Rotate map with heading'** + String get rotateMapWithHeading; + + /// No description provided for @showMapDebugInfo. + /// + /// In en, this message translates to: + /// **'Show map debug info'** + String get showMapDebugInfo; + + /// No description provided for @openMapInFullscreen. + /// + /// In en, this message translates to: + /// **'Open map in fullscreen'** + String get openMapInFullscreen; + + /// No description provided for @showSarMarkersLabel. + /// + /// In en, this message translates to: + /// **'Show SAR markers'** + String get showSarMarkersLabel; + + /// No description provided for @hideRepeatersOnMap. + /// + /// In en, this message translates to: + /// **'Hide repeaters on map'** + String get hideRepeatersOnMap; + + /// No description provided for @setMapScale. + /// + /// In en, this message translates to: + /// **'Set map scale'** + String get setMapScale; + + /// No description provided for @customMapScaleSaved. + /// + /// In en, this message translates to: + /// **'Custom map scale saved'** + String get customMapScaleSaved; + + /// No description provided for @voiceBitrate. + /// + /// In en, this message translates to: + /// **'Voice bitrate'** + String get voiceBitrate; + + /// No description provided for @voiceCompressor. + /// + /// In en, this message translates to: + /// **'Voice compressor'** + String get voiceCompressor; + + /// No description provided for @voiceLimiter. + /// + /// In en, this message translates to: + /// **'Voice limiter'** + String get voiceLimiter; + + /// No description provided for @micAutoGain. + /// + /// In en, this message translates to: + /// **'Mic auto gain'** + String get micAutoGain; + + /// No description provided for @echoCancellation. + /// + /// In en, this message translates to: + /// **'Echo cancellation'** + String get echoCancellation; + + /// No description provided for @noiseSuppression. + /// + /// In en, this message translates to: + /// **'Noise suppression'** + String get noiseSuppression; + + /// No description provided for @trimSilenceInVoiceMessages. + /// + /// In en, this message translates to: + /// **'Trim silence in voice messages'** + String get trimSilenceInVoiceMessages; + + /// No description provided for @compressor. + /// + /// In en, this message translates to: + /// **'Compressor'** + String get compressor; + + /// No description provided for @limiter. + /// + /// In en, this message translates to: + /// **'Limiter'** + String get limiter; + + /// No description provided for @autoGain. + /// + /// In en, this message translates to: + /// **'Auto gain'** + String get autoGain; + + /// No description provided for @echoCancel. + /// + /// In en, this message translates to: + /// **'Echo cancel'** + String get echoCancel; + + /// No description provided for @noiseSuppress. + /// + /// In en, this message translates to: + /// **'Noise suppress'** + String get noiseSuppress; + + /// No description provided for @silenceTrim. + /// + /// In en, this message translates to: + /// **'Silence trim'** + String get silenceTrim; + + /// No description provided for @maxImageSize. + /// + /// In en, this message translates to: + /// **'Max image size'** + String get maxImageSize; + + /// No description provided for @imageCompression. + /// + /// In en, this message translates to: + /// **'Image compression'** + String get imageCompression; + + /// No description provided for @grayscale. + /// + /// In en, this message translates to: + /// **'Grayscale'** + String get grayscale; + + /// No description provided for @ultraMode. + /// + /// In en, this message translates to: + /// **'Ultra mode'** + String get ultraMode; + + /// No description provided for @fastPrivateGpsUpdates. + /// + /// In en, this message translates to: + /// **'Fast private GPS updates'** + String get fastPrivateGpsUpdates; + + /// No description provided for @movementThreshold. + /// + /// In en, this message translates to: + /// **'Movement threshold'** + String get movementThreshold; + + /// No description provided for @fastGpsMovementThreshold. + /// + /// In en, this message translates to: + /// **'Fast GPS movement threshold'** + String get fastGpsMovementThreshold; + + /// No description provided for @fastGpsActiveuseInterval. + /// + /// In en, this message translates to: + /// **'Fast GPS active-use interval'** + String get fastGpsActiveuseInterval; + + /// No description provided for @activeuseUpdateInterval. + /// + /// In en, this message translates to: + /// **'Active-use update interval'** + String get activeuseUpdateInterval; + + /// No description provided for @repeatNearbyTraffic. + /// + /// In en, this message translates to: + /// **'Repeat nearby traffic'** + String get repeatNearbyTraffic; + + /// No description provided for @relayThroughRepeatersAcrossTheMesh. + /// + /// In en, this message translates to: + /// **'Relay through repeaters across the mesh'** + String get relayThroughRepeatersAcrossTheMesh; + + /// No description provided for @nearbyOnlyWithoutRepeaterFlooding. + /// + /// In en, this message translates to: + /// **'Nearby only, without repeater flooding'** + String get nearbyOnlyWithoutRepeaterFlooding; + + /// No description provided for @multihop. + /// + /// In en, this message translates to: + /// **'Multi-hop'** + String get multihop; + + /// No description provided for @createProfile. + /// + /// In en, this message translates to: + /// **'Create Profile'** + String get createProfile; + + /// No description provided for @renameProfile. + /// + /// In en, this message translates to: + /// **'Rename Profile'** + String get renameProfile; + + /// No description provided for @newProfile. + /// + /// In en, this message translates to: + /// **'New Profile'** + String get newProfile; + + /// No description provided for @manageProfiles. + /// + /// In en, this message translates to: + /// **'Manage profiles'** + String get manageProfiles; + + /// No description provided for @enableProfilesToStartManagingThem. + /// + /// In en, this message translates to: + /// **'Enable profiles to start managing them.'** + String get enableProfilesToStartManagingThem; + + /// No description provided for @openMessage. + /// + /// In en, this message translates to: + /// **'Open message'** + String get openMessage; + + /// No description provided for @jumpToTheRelatedSarMessage. + /// + /// In en, this message translates to: + /// **'Jump to the related SAR message'** + String get jumpToTheRelatedSarMessage; + + /// No description provided for @removeSarMarker. + /// + /// In en, this message translates to: + /// **'Remove SAR marker'** + String get removeSarMarker; + + /// No description provided for @pleaseSelectADestinationToSendSarMarker. + /// + /// In en, this message translates to: + /// **'Please select a destination to send SAR marker'** + String get pleaseSelectADestinationToSendSarMarker; + + /// No description provided for @sarMarkerBroadcastToPublicChannel. + /// + /// In en, this message translates to: + /// **'SAR marker broadcast to public channel'** + String get sarMarkerBroadcastToPublicChannel; + + /// No description provided for @sarMarkerSentToRoom. + /// + /// In en, this message translates to: + /// **'SAR marker sent to room'** + String get sarMarkerSentToRoom; + + /// No description provided for @loadFromGallery. + /// + /// In en, this message translates to: + /// **'Load from gallery'** + String get loadFromGallery; + + /// No description provided for @replaceImage. + /// + /// In en, this message translates to: + /// **'Replace image'** + String get replaceImage; + + /// No description provided for @selectFromGallery. + /// + /// In en, this message translates to: + /// **'Select from gallery'** + String get selectFromGallery; + + /// No description provided for @team. + /// + /// In en, this message translates to: + /// **'Team'** + String get team; + + /// No description provided for @found. + /// + /// In en, this message translates to: + /// **'Found'** + String get found; + + /// No description provided for @staging. + /// + /// In en, this message translates to: + /// **'Staging'** + String get staging; + + /// No description provided for @object. + /// + /// In en, this message translates to: + /// **'Object'** + String get object; + + /// No description provided for @quiet. + /// + /// In en, this message translates to: + /// **'Quiet'** + String get quiet; + + /// No description provided for @busy. + /// + /// In en, this message translates to: + /// **'Busy'** + String get busy; + + /// No description provided for @searchMessages. + /// + /// In en, this message translates to: + /// **'Search messages'** + String get searchMessages; + + /// No description provided for @sendImageFromGallery. + /// + /// In en, this message translates to: + /// **'Send image from gallery'** + String get sendImageFromGallery; + + /// No description provided for @takePhoto. + /// + /// In en, this message translates to: + /// **'Take photo'** + String get takePhoto; + + /// No description provided for @allMessages. + /// + /// In en, this message translates to: + /// **'All messages'** + String get allMessages; + + /// No description provided for @sendToPublicChannel. + /// + /// In en, this message translates to: + /// **'Send to Public Channel?'** + String get sendToPublicChannel; + + /// No description provided for @selectMarkerTypeAndDestination. + /// + /// In en, this message translates to: + /// **'Select marker type and destination'** + String get selectMarkerTypeAndDestination; + + /// No description provided for @noDestinationsAvailableLabel. + /// + /// In en, this message translates to: + /// **'No destinations available'** + String get noDestinationsAvailableLabel; + + /// No description provided for @image. + /// + /// In en, this message translates to: + /// **'Image'** + String get image; + + /// No description provided for @format. + /// + /// In en, this message translates to: + /// **'Format'** + String get format; + + /// No description provided for @dimensions. + /// + /// In en, this message translates to: + /// **'Dimensions'** + String get dimensions; + + /// No description provided for @segments. + /// + /// In en, this message translates to: + /// **'Segments'** + String get segments; + + /// No description provided for @transfers. + /// + /// In en, this message translates to: + /// **'Transfers'** + String get transfers; + + /// No description provided for @downloadedBy. + /// + /// In en, this message translates to: + /// **'Downloaded by'** + String get downloadedBy; + + /// No description provided for @saveDiscoverySettings. + /// + /// In en, this message translates to: + /// **'Save discovery settings'** + String get saveDiscoverySettings; + + /// No description provided for @savePublicInfo. + /// + /// In en, this message translates to: + /// **'Save public info'** + String get savePublicInfo; + + /// No description provided for @saveRadioSettings. + /// + /// In en, this message translates to: + /// **'Save radio settings'** + String get saveRadioSettings; + + /// No description provided for @savePath. + /// + /// In en, this message translates to: + /// **'Save Path'** + String get savePath; + + /// No description provided for @wipeDeviceData. + /// + /// In en, this message translates to: + /// **'Wipe device data'** + String get wipeDeviceData; + + /// No description provided for @wipeDevice. + /// + /// In en, this message translates to: + /// **'Wipe device'** + String get wipeDevice; + + /// No description provided for @destructiveDeviceActions. + /// + /// In en, this message translates to: + /// **'Destructive device actions.'** + String get destructiveDeviceActions; + + /// No description provided for @chooseAPresetOrFinetuneCustomRadioSettings. + /// + /// In en, this message translates to: + /// **'Choose a preset or fine-tune custom radio settings.'** + String get chooseAPresetOrFinetuneCustomRadioSettings; + + /// No description provided for @used. + /// + /// In en, this message translates to: + /// **'Used'** + String get used; + + /// No description provided for @total. + /// + /// In en, this message translates to: + /// **'Total'** + String get total; + + /// No description provided for @renameValue. + /// + /// In en, this message translates to: + /// **'Rename value'** + String get renameValue; + + /// No description provided for @customizeFields. + /// + /// In en, this message translates to: + /// **'Customize fields'** + String get customizeFields; + + /// No description provided for @livePreview. + /// + /// In en, this message translates to: + /// **'Live preview'** + String get livePreview; + + /// No description provided for @refreshSchedule. + /// + /// In en, this message translates to: + /// **'Refresh schedule'** + String get refreshSchedule; + + /// No description provided for @noResponse. + /// + /// In en, this message translates to: + /// **'No response'** + String get noResponse; + + /// No description provided for @refreshing. + /// + /// In en, this message translates to: + /// **'Refreshing'** + String get refreshing; + + /// No description provided for @unavailable. + /// + /// In en, this message translates to: + /// **'Unavailable'** + String get unavailable; + + /// No description provided for @pickARelayOrNodeToWatchInSensors. + /// + /// In en, this message translates to: + /// **'Pick a relay or node to watch in Sensors.'** + String get pickARelayOrNodeToWatchInSensors; + + /// No description provided for @publicKeyLabel. + /// + /// In en, this message translates to: + /// **'Public key'** + String get publicKeyLabel; + + /// No description provided for @alreadyInContacts. + /// + /// In en, this message translates to: + /// **'Already in contacts'** + String get alreadyInContacts; + + /// No description provided for @connectToADeviceBeforeAddingContacts. + /// + /// In en, this message translates to: + /// **'Connect to a device before adding contacts'** + String get connectToADeviceBeforeAddingContacts; + + /// No description provided for @fromContacts. + /// + /// In en, this message translates to: + /// **'From contacts'** + String get fromContacts; + + /// No description provided for @onlineOnly. + /// + /// In en, this message translates to: + /// **'Online only'** + String get onlineOnly; + + /// No description provided for @inBoth. + /// + /// In en, this message translates to: + /// **'In both'** + String get inBoth; + + /// No description provided for @source. + /// + /// In en, this message translates to: + /// **'Source'** + String get source; + + /// No description provided for @allMessagesCleared. + /// + /// In en, this message translates to: + /// **'All messages cleared'** + String get allMessagesCleared; + + /// No description provided for @onlineTraceDatabaseCleared. + /// + /// In en, this message translates to: + /// **'Online trace database cleared'** + String get onlineTraceDatabaseCleared; + + /// No description provided for @packetLogsCleared. + /// + /// In en, this message translates to: + /// **'Packet logs cleared'** + String get packetLogsCleared; + + /// No description provided for @hexDataCopiedToClipboard. + /// + /// In en, this message translates to: + /// **'Hex data copied to clipboard'** + String get hexDataCopiedToClipboard; + + /// No description provided for @developerModeEnabled. + /// + /// In en, this message translates to: + /// **'Developer mode enabled'** + String get developerModeEnabled; + + /// No description provided for @developerModeDisabled. + /// + /// In en, this message translates to: + /// **'Developer mode disabled'** + String get developerModeDisabled; + + /// No description provided for @clipboardIsEmpty. + /// + /// In en, this message translates to: + /// **'Clipboard is empty'** + String get clipboardIsEmpty; + + /// No description provided for @contactImported. + /// + /// In en, this message translates to: + /// **'Contact imported'** + String get contactImported; + + /// No description provided for @contactLinkCopiedToClipboard. + /// + /// In en, this message translates to: + /// **'Contact link copied to clipboard'** + String get contactLinkCopiedToClipboard; + + /// No description provided for @failedToExportContact. + /// + /// In en, this message translates to: + /// **'Failed to export contact'** + String get failedToExportContact; + + /// No description provided for @noLogsToExport. + /// + /// In en, this message translates to: + /// **'No logs to export'** + String get noLogsToExport; + + /// No description provided for @exportAsCsv. + /// + /// In en, this message translates to: + /// **'Export as CSV'** + String get exportAsCsv; + + /// No description provided for @exportAsText. + /// + /// In en, this message translates to: + /// **'Export as Text'** + String get exportAsText; + + /// No description provided for @receivedRfc3339. + /// + /// In en, this message translates to: + /// **'Received (RFC3339)'** + String get receivedRfc3339; + + /// No description provided for @buildTime. + /// + /// In en, this message translates to: + /// **'Build Time'** + String get buildTime; + + /// No description provided for @downloadUrlNotAvailable. + /// + /// In en, this message translates to: + /// **'Download URL not available'** + String get downloadUrlNotAvailable; + + /// No description provided for @cannotOpenDownloadUrl. + /// + /// In en, this message translates to: + /// **'Cannot open download URL'** + String get cannotOpenDownloadUrl; + + /// No description provided for @updateCheckIsOnlyAvailableOnAndroid. + /// + /// In en, this message translates to: + /// **'Update check is only available on Android'** + String get updateCheckIsOnlyAvailableOnAndroid; + + /// No description provided for @youAreRunningTheLatestVersion. + /// + /// In en, this message translates to: + /// **'You are running the latest version'** + String get youAreRunningTheLatestVersion; + + /// No description provided for @updateAvailableButDownloadUrlNotFound. + /// + /// In en, this message translates to: + /// **'Update available but download URL not found'** + String get updateAvailableButDownloadUrlNotFound; + + /// No description provided for @startTictactoe. + /// + /// In en, this message translates to: + /// **'Start Tic-Tac-Toe'** + String get startTictactoe; + + /// No description provided for @tictactoeUnavailable. + /// + /// In en, this message translates to: + /// **'Tic-Tac-Toe unavailable'** + String get tictactoeUnavailable; + + /// No description provided for @tictactoeOpponentUnknown. + /// + /// In en, this message translates to: + /// **'Tic-Tac-Toe: opponent unknown'** + String get tictactoeOpponentUnknown; + + /// No description provided for @tictactoeWaitingForStart. + /// + /// In en, this message translates to: + /// **'Tic-Tac-Toe: waiting for start'** + String get tictactoeWaitingForStart; + + /// No description provided for @acceptsShareLinks. + /// + /// In en, this message translates to: + /// **'Accepts share links'** + String get acceptsShareLinks; + + /// No description provided for @supportsRawHex. + /// + /// In en, this message translates to: + /// **'Supports raw hex'** + String get supportsRawHex; + + /// No description provided for @clipboardfriendly. + /// + /// In en, this message translates to: + /// **'Clipboard-friendly'** + String get clipboardfriendly; + + /// No description provided for @captured. + /// + /// In en, this message translates to: + /// **'Captured'** + String get captured; + + /// No description provided for @size. + /// + /// In en, this message translates to: + /// **'Size'** + String get size; + + /// No description provided for @noCustomChannelsToClear. + /// + /// In en, this message translates to: + /// **'No custom channels to clear.'** + String get noCustomChannelsToClear; + + /// No description provided for @noDeviceContactsToClear. + /// + /// In en, this message translates to: + /// **'No device contacts to clear.'** + String get noDeviceContactsToClear; + + /// No description provided for @aToZ. + /// + /// In en, this message translates to: + /// **'A-Z'** + String get aToZ; + + /// No description provided for @add. + /// + /// In en, this message translates to: + /// **'Add'** + String get add; + + /// No description provided for @addAdditionalDetails. + /// + /// In en, this message translates to: + /// **'Add additional details'** + String get addAdditionalDetails; + + /// No description provided for @addPeer. + /// + /// In en, this message translates to: + /// **'Add Peer'** + String get addPeer; + + /// No description provided for @addPeerManually. + /// + /// In en, this message translates to: + /// **'Add peer manually'** + String get addPeerManually; + + /// No description provided for @addedToContacts. + /// + /// In en, this message translates to: + /// **'{name} added to contacts'** + String addedToContacts(String name); + + /// No description provided for @addedToSensors. + /// + /// In en, this message translates to: + /// **'{name} added to Sensors'** + String addedToSensors(String name); + + /// No description provided for @allowAll. + /// + /// In en, this message translates to: + /// **'Allow all'** + String get allowAll; + + /// No description provided for @anonymousRxStats. + /// + /// In en, this message translates to: + /// **'Anonymous RX stats'** + String get anonymousRxStats; + + /// No description provided for @appearance. + /// + /// In en, this message translates to: + /// **'Appearance'** + String get appearance; + + /// No description provided for @ascii. + /// + /// In en, this message translates to: + /// **'ASCII'** + String get ascii; + + /// No description provided for @baseTelemetry. + /// + /// In en, this message translates to: + /// **'Base telemetry'** + String get baseTelemetry; + + /// No description provided for @blePin. + /// + /// In en, this message translates to: + /// **'BLE PIN'** + String get blePin; + + /// No description provided for @buzzerAlerts. + /// + /// In en, this message translates to: + /// **'Buzzer alerts'** + String get buzzerAlerts; + + /// No description provided for @cancelImageReceive. + /// + /// In en, this message translates to: + /// **'Cancel image receive'** + String get cancelImageReceive; + + /// No description provided for @choose. + /// + /// In en, this message translates to: + /// **'Choose'** + String get choose; + + /// No description provided for @chooseWhichTabsAndContactSectionsToShow. + /// + /// In en, this message translates to: + /// **'Choose which tabs and contact sections to show'** + String get chooseWhichTabsAndContactSectionsToShow; + + /// No description provided for @clearCache. + /// + /// In en, this message translates to: + /// **'Clear cache'** + String get clearCache; + + /// No description provided for @clearHistory. + /// + /// In en, this message translates to: + /// **'Clear history'** + String get clearHistory; + + /// No description provided for @clearLiveView. + /// + /// In en, this message translates to: + /// **'Clear live view'** + String get clearLiveView; + + /// No description provided for @clearLogs. + /// + /// In en, this message translates to: + /// **'Clear logs'** + String get clearLogs; + + /// No description provided for @clearOfflineCache. + /// + /// In en, this message translates to: + /// **'Clear offline cache?'** + String get clearOfflineCache; + + /// No description provided for @clearOverlay. + /// + /// In en, this message translates to: + /// **'Clear overlay'** + String get clearOverlay; + + /// No description provided for @clearRouteOnlyAfterAllRetriesFail. + /// + /// In en, this message translates to: + /// **'Clear route only after all retries fail'** + String get clearRouteOnlyAfterAllRetriesFail; + + /// No description provided for @codecBitrateAndAudioProcessing. + /// + /// In en, this message translates to: + /// **'Codec, bitrate, and audio processing'** + String get codecBitrateAndAudioProcessing; + + /// No description provided for @connectByIpAddress. + /// + /// In en, this message translates to: + /// **'Connect by IP address'** + String get connectByIpAddress; + + /// No description provided for @controlWhichAlertsYouReceive. + /// + /// In en, this message translates to: + /// **'Control which alerts you receive'** + String get controlWhichAlertsYouReceive; + + /// No description provided for @copyFullHex. + /// + /// In en, this message translates to: + /// **'Copy full hex'** + String get copyFullHex; + + /// No description provided for @copyRawResponse. + /// + /// In en, this message translates to: + /// **'Copy raw response'** + String get copyRawResponse; + + /// No description provided for @couldNotLoadMetHistory. + /// + /// In en, this message translates to: + /// **'Could not load MET history'** + String get couldNotLoadMetHistory; + + /// No description provided for @customizeSensor. + /// + /// In en, this message translates to: + /// **'Customize {name}'** + String customizeSensor(String name); + + /// No description provided for @data. + /// + /// In en, this message translates to: + /// **'Data'** + String get data; + + /// No description provided for @deleteGroup. + /// + /// In en, this message translates to: + /// **'Delete group'** + String get deleteGroup; + + /// No description provided for @deleteStyleConfirm. + /// + /// In en, this message translates to: + /// **'Delete {name}?'** + String deleteStyleConfirm(String name); + + /// No description provided for @deleteThisDrawing. + /// + /// In en, this message translates to: + /// **'Delete this {type}?'** + String deleteThisDrawing(String type); + + /// No description provided for @deny. + /// + /// In en, this message translates to: + /// **'Deny'** + String get deny; + + /// No description provided for @deviceInfo. + /// + /// In en, this message translates to: + /// **'Device info'** + String get deviceInfo; + + /// No description provided for @deviceSettings. + /// + /// In en, this message translates to: + /// **'Device Settings'** + String get deviceSettings; + + /// No description provided for @deviceTimeSynced. + /// + /// In en, this message translates to: + /// **'Device time synced to this phone.'** + String get deviceTimeSynced; + + /// No description provided for @disableFastGpsPublishing. + /// + /// In en, this message translates to: + /// **'Disable fast GPS publishing'** + String get disableFastGpsPublishing; + + /// No description provided for @displayMarkersAndTraceDatabase. + /// + /// In en, this message translates to: + /// **'Display, markers, and trace database'** + String get displayMarkersAndTraceDatabase; + + /// No description provided for @download. + /// + /// In en, this message translates to: + /// **'Download'** + String get download; + + /// No description provided for @environmentalTelemetry. + /// + /// In en, this message translates to: + /// **'Environmental telemetry'** + String get environmentalTelemetry; + + /// No description provided for @errorCheckingForUpdates. + /// + /// In en, this message translates to: + /// **'Error checking for updates: {error}'** + String errorCheckingForUpdates(String error); + + /// No description provided for @errorGeneric. + /// + /// In en, this message translates to: + /// **'Error: {error}'** + String errorGeneric(String error); + + /// No description provided for @errorOpeningDownload. + /// + /// In en, this message translates to: + /// **'Error opening download: {error}'** + String errorOpeningDownload(String error); + + /// No description provided for @exportLogs. + /// + /// In en, this message translates to: + /// **'Export logs'** + String get exportLogs; + + /// No description provided for @extraAggressiveAvifCompression. + /// + /// In en, this message translates to: + /// **'Extra-aggressive AVIF compression'** + String get extraAggressiveAvifCompression; + + /// No description provided for @failedToAddContact. + /// + /// In en, this message translates to: + /// **'Failed to add contact: {error}'** + String failedToAddContact(String error); + + /// No description provided for @failedToAddName. + /// + /// In en, this message translates to: + /// **'Failed to add {name}: {error}'** + String failedToAddName(String name, String error); + + /// No description provided for @failedToClearChannels. + /// + /// In en, this message translates to: + /// **'Failed to clear channels: {error}'** + String failedToClearChannels(String error); + + /// No description provided for @failedToClearContacts. + /// + /// In en, this message translates to: + /// **'Failed to clear contacts: {error}'** + String failedToClearContacts(String error); + + /// No description provided for @failedToConnectViaSerial. + /// + /// In en, this message translates to: + /// **'Failed to connect via serial'** + String get failedToConnectViaSerial; + + /// No description provided for @failedToLoadPreviewImage. + /// + /// In en, this message translates to: + /// **'Failed to load preview image: {error}'** + String failedToLoadPreviewImage(String error); + + /// No description provided for @failedToLoadTrace. + /// + /// In en, this message translates to: + /// **'Failed to load trace: {error}'** + String failedToLoadTrace(String error); + + /// No description provided for @failedToSendSarMarker. + /// + /// In en, this message translates to: + /// **'Failed to send SAR marker: {error}'** + String failedToSendSarMarker(String error); + + /// No description provided for @failedToSetBuzzerMode. + /// + /// In en, this message translates to: + /// **'Failed to set buzzer mode: {error}'** + String failedToSetBuzzerMode(String error); + + /// No description provided for @failedToSetGpsMode. + /// + /// In en, this message translates to: + /// **'Failed to set GPS mode: {error}'** + String failedToSetGpsMode(String error); + + /// No description provided for @failedToSyncDeviceTime. + /// + /// In en, this message translates to: + /// **'Failed to sync device time: {error}'** + String failedToSyncDeviceTime(String error); + + /// No description provided for @failedToWipeDeviceData. + /// + /// In en, this message translates to: + /// **'Failed to wipe device data: {error}'** + String failedToWipeDeviceData(String error); + + /// No description provided for @fastGpsTargetChannel. + /// + /// In en, this message translates to: + /// **'Fast GPS target channel'** + String get fastGpsTargetChannel; + + /// No description provided for @fastUpdatesThresholdsAndPermissions. + /// + /// In en, this message translates to: + /// **'Fast updates, thresholds, and permissions'** + String get fastUpdatesThresholdsAndPermissions; + + /// No description provided for @fetchingCatalog. + /// + /// In en, this message translates to: + /// **'Fetching catalog...'** + String get fetchingCatalog; + + /// No description provided for @fifteenMinutes. + /// + /// In en, this message translates to: + /// **'15 minutes'** + String get fifteenMinutes; + + /// No description provided for @fifteenSeconds. + /// + /// In en, this message translates to: + /// **'15 seconds'** + String get fifteenSeconds; + + /// No description provided for @filterByDirection. + /// + /// In en, this message translates to: + /// **'Filter by direction'** + String get filterByDirection; + + /// No description provided for @finish. + /// + /// In en, this message translates to: + /// **'Finish'** + String get finish; + + /// No description provided for @fiveMinutes. + /// + /// In en, this message translates to: + /// **'5 minutes'** + String get fiveMinutes; + + /// No description provided for @fiveSeconds. + /// + /// In en, this message translates to: + /// **'5 seconds'** + String get fiveSeconds; + + /// No description provided for @fix. + /// + /// In en, this message translates to: + /// **'Fix'** + String get fix; + + /// No description provided for @gps. + /// + /// In en, this message translates to: + /// **'GPS'** + String get gps; + + /// No description provided for @gpsAdvertPolicy. + /// + /// In en, this message translates to: + /// **'GPS advert policy'** + String get gpsAdvertPolicy; + + /// No description provided for @gpsAndLocation. + /// + /// In en, this message translates to: + /// **'GPS & Location'** + String get gpsAndLocation; + + /// No description provided for @gpsDiagnostics. + /// + /// In en, this message translates to: + /// **'GPS diagnostics'** + String get gpsDiagnostics; + + /// No description provided for @gpsSection. + /// + /// In en, this message translates to: + /// **'GPS'** + String get gpsSection; + + /// No description provided for @help. + /// + /// In en, this message translates to: + /// **'Help'** + String get help; + + /// No description provided for @hex. + /// + /// In en, this message translates to: + /// **'Hex'** + String get hex; + + /// No description provided for @hidden. + /// + /// In en, this message translates to: + /// **'Hidden'** + String get hidden; + + /// No description provided for @howTheRadioAutoAddsDiscoveredNodes. + /// + /// In en, this message translates to: + /// **'How the radio auto-adds discovered nodes.'** + String get howTheRadioAutoAddsDiscoveredNodes; + + /// No description provided for @images. + /// + /// In en, this message translates to: + /// **'Images'** + String get images; + + /// No description provided for @importProfile. + /// + /// In en, this message translates to: + /// **'Import profile'** + String get importProfile; + + /// No description provided for @lastFix. + /// + /// In en, this message translates to: + /// **'Last fix'** + String get lastFix; + + /// No description provided for @latest. + /// + /// In en, this message translates to: + /// **'Latest'** + String get latest; + + /// No description provided for @latitude. + /// + /// In en, this message translates to: + /// **'Latitude'** + String get latitude; + + /// No description provided for @loadASavedRegion. + /// + /// In en, this message translates to: + /// **'Load a saved region'** + String get loadASavedRegion; + + /// No description provided for @loading. + /// + /// In en, this message translates to: + /// **'Loading'** + String get loading; + + /// No description provided for @locationSharingHardwareAndUpdateInterval. + /// + /// In en, this message translates to: + /// **'Location sharing, hardware, and update interval.'** + String get locationSharingHardwareAndUpdateInterval; + + /// No description provided for @locationTelemetry. + /// + /// In en, this message translates to: + /// **'Location telemetry'** + String get locationTelemetry; + + /// No description provided for @lockDestination. + /// + /// In en, this message translates to: + /// **'Lock destination'** + String get lockDestination; + + /// No description provided for @longitude. + /// + /// In en, this message translates to: + /// **'Longitude'** + String get longitude; + + /// No description provided for @mapStyle. + /// + /// In en, this message translates to: + /// **'Map Style'** + String get mapStyle; + + /// No description provided for @max. + /// + /// In en, this message translates to: + /// **'Max'** + String get max; + + /// No description provided for @maxZoom. + /// + /// In en, this message translates to: + /// **'Max Zoom'** + String get maxZoom; + + /// No description provided for @messaging. + /// + /// In en, this message translates to: + /// **'Messaging'** + String get messaging; + + /// No description provided for @metHistory. + /// + /// In en, this message translates to: + /// **'MET history'** + String get metHistory; + + /// No description provided for @min. + /// + /// In en, this message translates to: + /// **'Min'** + String get min; + + /// No description provided for @minZoom. + /// + /// In en, this message translates to: + /// **'Min Zoom'** + String get minZoom; + + /// No description provided for @moveDown. + /// + /// In en, this message translates to: + /// **'Move down'** + String get moveDown; + + /// No description provided for @moveUp. + /// + /// In en, this message translates to: + /// **'Move up'** + String get moveUp; + + /// No description provided for @multiAckMode. + /// + /// In en, this message translates to: + /// **'Multi-ACK mode'** + String get multiAckMode; + + /// No description provided for @multiDeviceWorkspaceManagement. + /// + /// In en, this message translates to: + /// **'Multi-device workspace management'** + String get multiDeviceWorkspaceManagement; + + /// No description provided for @myLocation. + /// + /// In en, this message translates to: + /// **'My location'** + String get myLocation; + + /// No description provided for @nameAndTelemetryShared. + /// + /// In en, this message translates to: + /// **'Name and telemetry shared with other devices.'** + String get nameAndTelemetryShared; + + /// No description provided for @newVersionAvailable. + /// + /// In en, this message translates to: + /// **'New version: {version}'** + String newVersionAvailable(String version); + + /// No description provided for @newer. + /// + /// In en, this message translates to: + /// **'Newer'** + String get newer; + + /// No description provided for @noNeighboursFound. + /// + /// In en, this message translates to: + /// **'No neighbours found'** + String get noNeighboursFound; + + /// No description provided for @noServersFound. + /// + /// In en, this message translates to: + /// **'No servers found'** + String get noServersFound; + + /// No description provided for @notSet. + /// + /// In en, this message translates to: + /// **'Not set'** + String get notSet; + + /// No description provided for @notifications. + /// + /// In en, this message translates to: + /// **'Notifications'** + String get notifications; + + /// No description provided for @offZeroSeconds. + /// + /// In en, this message translates to: + /// **'Off (0 s)'** + String get offZeroSeconds; + + /// No description provided for @offlineMaps. + /// + /// In en, this message translates to: + /// **'Offline Maps'** + String get offlineMaps; + + /// No description provided for @older. + /// + /// In en, this message translates to: + /// **'Older'** + String get older; + + /// No description provided for @oneByteMode0. + /// + /// In en, this message translates to: + /// **'1 byte (mode 0)'** + String get oneByteMode0; + + /// No description provided for @oneHour. + /// + /// In en, this message translates to: + /// **'1 hour'** + String get oneHour; + + /// No description provided for @oneMinute. + /// + /// In en, this message translates to: + /// **'1 minute'** + String get oneMinute; + + /// No description provided for @openPacketLogs. + /// + /// In en, this message translates to: + /// **'Open packet logs'** + String get openPacketLogs; + + /// No description provided for @packetTypeHelp. + /// + /// In en, this message translates to: + /// **'Packet type help'** + String get packetTypeHelp; + + /// No description provided for @pasteShareLinkOrHexAdvert. + /// + /// In en, this message translates to: + /// **'Paste a share link or hex advert'** + String get pasteShareLinkOrHexAdvert; + + /// No description provided for @pathHash. + /// + /// In en, this message translates to: + /// **'Path hash'** + String get pathHash; + + /// No description provided for @pathSize. + /// + /// In en, this message translates to: + /// **'Path size'** + String get pathSize; + + /// No description provided for @ping. + /// + /// In en, this message translates to: + /// **'Ping'** + String get ping; + + /// No description provided for @polygon. + /// + /// In en, this message translates to: + /// **'Polygon'** + String get polygon; + + /// No description provided for @rawResponseCopied. + /// + /// In en, this message translates to: + /// **'Raw response copied'** + String get rawResponseCopied; + + /// No description provided for @resendThroughNearestRepeaterOnFailure. + /// + /// In en, this message translates to: + /// **'Resend through nearest repeater on failure'** + String get resendThroughNearestRepeaterOnFailure; + + /// No description provided for @resolveContact. + /// + /// In en, this message translates to: + /// **'Resolve contact'** + String get resolveContact; + + /// No description provided for @routingRetriesAndDestinationLock. + /// + /// In en, this message translates to: + /// **'Routing, retries, and destination lock'** + String get routingRetriesAndDestinationLock; + + /// No description provided for @samples. + /// + /// In en, this message translates to: + /// **'Samples'** + String get samples; + + /// No description provided for @satellites. + /// + /// In en, this message translates to: + /// **'Satellites'** + String get satellites; + + /// No description provided for @saving. + /// + /// In en, this message translates to: + /// **'Saving...'** + String get saving; + + /// No description provided for @search. + /// + /// In en, this message translates to: + /// **'Search'** + String get search; + + /// No description provided for @searchByNameOrToken. + /// + /// In en, this message translates to: + /// **'Search by name or token'** + String get searchByNameOrToken; + + /// No description provided for @searchDiscoveredNodes. + /// + /// In en, this message translates to: + /// **'Search discovered nodes'** + String get searchDiscoveredNodes; + + /// No description provided for @searchInCurrentFilter. + /// + /// In en, this message translates to: + /// **'Search in current filter'** + String get searchInCurrentFilter; + + /// No description provided for @searchLogs. + /// + /// In en, this message translates to: + /// **'Search logs...'** + String get searchLogs; + + /// No description provided for @searchSensors. + /// + /// In en, this message translates to: + /// **'Search sensors'** + String get searchSensors; + + /// No description provided for @searchThisSection. + /// + /// In en, this message translates to: + /// **'Search this section'** + String get searchThisSection; + + /// No description provided for @selectedRelays. + /// + /// In en, this message translates to: + /// **'Selected relays'** + String get selectedRelays; + + /// No description provided for @sensor. + /// + /// In en, this message translates to: + /// **'Sensor'** + String get sensor; + + /// No description provided for @serialError. + /// + /// In en, this message translates to: + /// **'Serial error: {error}'** + String serialError(String error); + + /// No description provided for @setPathForContact. + /// + /// In en, this message translates to: + /// **'Set Path for {name}'** + String setPathForContact(String name); + + /// No description provided for @shareLiveGps. + /// + /// In en, this message translates to: + /// **'Share live GPS'** + String get shareLiveGps; + + /// No description provided for @shareMyTiles. + /// + /// In en, this message translates to: + /// **'Share my tiles'** + String get shareMyTiles; + + /// No description provided for @sharingLocationFromPhone. + /// + /// In en, this message translates to: + /// **'Sharing location on this channel from the phone.'** + String get sharingLocationFromPhone; + + /// No description provided for @sharingLocationFromRadio. + /// + /// In en, this message translates to: + /// **'Sharing location on this channel from the radio.'** + String get sharingLocationFromRadio; + + /// No description provided for @signalDbm. + /// + /// In en, this message translates to: + /// **'Signal {rssi} dBm'** + String signalDbm(String rssi); + + /// No description provided for @sizeCompressionAndPreview. + /// + /// In en, this message translates to: + /// **'Size, compression, and preview'** + String get sizeCompressionAndPreview; + + /// No description provided for @simpleMode. + /// + /// In en, this message translates to: + /// **'Simple mode'** + String get simpleMode; + + /// No description provided for @simpleModeDescription. + /// + /// In en, this message translates to: + /// **'Only chat and map with favourite contacts'** + String get simpleModeDescription; + + /// No description provided for @simpleModeRequiresLockedDestination. + /// + /// In en, this message translates to: + /// **'Enable \"Lock destination\" with a channel first to use Simple mode'** + String get simpleModeRequiresLockedDestination; + + /// No description provided for @noFavouriteContacts. + /// + /// In en, this message translates to: + /// **'No favourite contacts'** + String get noFavouriteContacts; + + /// No description provided for @skip. + /// + /// In en, this message translates to: + /// **'Skip'** + String get skip; + + /// No description provided for @smallerFileSize. + /// + /// In en, this message translates to: + /// **'Smaller file size'** + String get smallerFileSize; + + /// No description provided for @sort. + /// + /// In en, this message translates to: + /// **'Sort'** + String get sort; + + /// No description provided for @stoppedSharingLocation. + /// + /// In en, this message translates to: + /// **'Stopped sharing location on this channel.'** + String get stoppedSharingLocation; + + /// No description provided for @suppressNotificationsWhileInForeground. + /// + /// In en, this message translates to: + /// **'Suppress notifications while in foreground'** + String get suppressNotificationsWhileInForeground; + + /// No description provided for @tabsAndNavigation. + /// + /// In en, this message translates to: + /// **'Tabs & Navigation'** + String get tabsAndNavigation; + + /// No description provided for @templatesAndTutorials. + /// + /// In en, this message translates to: + /// **'Templates and tutorials'** + String get templatesAndTutorials; + + /// No description provided for @tenMinutes. + /// + /// In en, this message translates to: + /// **'10 minutes'** + String get tenMinutes; + + /// No description provided for @tenSeconds. + /// + /// In en, this message translates to: + /// **'10 seconds'** + String get tenSeconds; + + /// No description provided for @testSendUpdate. + /// + /// In en, this message translates to: + /// **'Test send update'** + String get testSendUpdate; + + /// No description provided for @themeLanguageAndDisplayPreferences. + /// + /// In en, this message translates to: + /// **'Theme, language, and display preferences'** + String get themeLanguageAndDisplayPreferences; + + /// No description provided for @thirtyMinutes. + /// + /// In en, this message translates to: + /// **'30 minutes'** + String get thirtyMinutes; + + /// No description provided for @thirtySeconds. + /// + /// In en, this message translates to: + /// **'30 seconds'** + String get thirtySeconds; + + /// No description provided for @threeBytesMode2. + /// + /// In en, this message translates to: + /// **'3 bytes (mode 2)'** + String get threeBytesMode2; + + /// No description provided for @trafficStatsMessageHistoryAndDeveloperTools. + /// + /// In en, this message translates to: + /// **'Traffic stats, message history, and developer tools'** + String get trafficStatsMessageHistoryAndDeveloperTools; + + /// No description provided for @twoBytesMode1. + /// + /// In en, this message translates to: + /// **'2 bytes (mode 1)'** + String get twoBytesMode1; + + /// No description provided for @twoMinutes. + /// + /// In en, this message translates to: + /// **'2 minutes'** + String get twoMinutes; + + /// No description provided for @undo. + /// + /// In en, this message translates to: + /// **'Undo'** + String get undo; + + /// No description provided for @updated. + /// + /// In en, this message translates to: + /// **'Updated'** + String get updated; + + /// No description provided for @useContactFlags. + /// + /// In en, this message translates to: + /// **'Use contact flags'** + String get useContactFlags; + + /// No description provided for @useSavedCoordinates. + /// + /// In en, this message translates to: + /// **'Use saved coordinates'** + String get useSavedCoordinates; + + /// No description provided for @view. + /// + /// In en, this message translates to: + /// **'View'** + String get view; + + /// No description provided for @viewNeighbours. + /// + /// In en, this message translates to: + /// **'View Neighbours'** + String get viewNeighbours; + + /// No description provided for @viewPublicStats. + /// + /// In en, this message translates to: + /// **'View public stats'** + String get viewPublicStats; + + /// No description provided for @setRegionScope. + /// + /// In en, this message translates to: + /// **'Set region scope'** + String get setRegionScope; + + /// No description provided for @regionScope. + /// + /// In en, this message translates to: + /// **'Region scope'** + String get regionScope; + + /// No description provided for @regionScopeNone. + /// + /// In en, this message translates to: + /// **'None (global)'** + String get regionScopeNone; + + /// No description provided for @clearRegionScope. + /// + /// In en, this message translates to: + /// **'Clear scope'** + String get clearRegionScope; + + /// No description provided for @regionScopeWarning. + /// + /// In en, this message translates to: + /// **'Only repeaters allowing this region will forward.'** + String get regionScopeWarning; + + /// No description provided for @discoverRegions. + /// + /// In en, this message translates to: + /// **'Discover from repeaters'** + String get discoverRegions; + + /// No description provided for @discoveringRegions. + /// + /// In en, this message translates to: + /// **'Discovering regions...'** + String get discoveringRegions; + + /// No description provided for @enterRegionName. + /// + /// In en, this message translates to: + /// **'Region name (e.g. auckland)'** + String get enterRegionName; + + /// No description provided for @noRegionsFound. + /// + /// In en, this message translates to: + /// **'No regions found on this repeater.'** + String get noRegionsFound; + + /// No description provided for @regionScopeSet. + /// + /// In en, this message translates to: + /// **'Region scope set to {name}'** + String regionScopeSet(String name); + + /// No description provided for @regionScopeCleared. + /// + /// In en, this message translates to: + /// **'Region scope cleared'** + String get regionScopeCleared; + + /// + /// + /// In en, this message translates to: + /// **'Select a channel, contact, or room first'** + String get selectDestinationFirst; + + /// + /// + /// In en, this message translates to: + /// **'Recipient is no longer available'** + String get recipientNoLongerAvailable; + + /// + /// + /// In en, this message translates to: + /// **'Failed to send: {error}'** + String failedToSendMessage(String error); + + /// + /// + /// In en, this message translates to: + /// **'Tic-Tac-Toe works only in direct messages. Choose a contact first.'** + String get tictactoeDirectOnly; + + /// + /// + /// In en, this message translates to: + /// **'Device key is unavailable'** + String get deviceKeyUnavailable; + + /// + /// + /// In en, this message translates to: + /// **'Image compression failed'** + String get imageCompressionFailed; + + /// + /// + /// In en, this message translates to: + /// **'Image fragmentation failed'** + String get imageFragmentationFailed; + + /// + /// + /// In en, this message translates to: + /// **'Failed to announce image'** + String get failedToAnnounceImage; + + /// + /// + /// In en, this message translates to: + /// **'No recipient selected'** + String get noRecipientSelected; + + /// + /// + /// In en, this message translates to: + /// **'Image send failed'** + String get imageSendFailed; + + /// + /// + /// In en, this message translates to: + /// **'Microphone permission required for voice'** + String get microphonePermissionRequiredForVoice; + + /// + /// + /// In en, this message translates to: + /// **'Recording discarded'** + String get recordingDiscarded; + + /// + /// + /// In en, this message translates to: + /// **'You are about to send {mediaType} to the Public Channel. This is not advised because everyone on the mesh may receive it. Choose a private or tagged channel unless this is what you want.'** + String publicChannelMediaWarning(String mediaType); + + /// + /// + /// In en, this message translates to: + /// **'Select a private channel to share your location.'** + String get selectPrivateChannelToShareLocation; + + /// + /// + /// In en, this message translates to: + /// **'Stop sharing my location'** + String get stopSharingMyLocation; + + /// + /// + /// In en, this message translates to: + /// **'Share my location'** + String get shareMyLocation; + + /// + /// + /// In en, this message translates to: + /// **'Stop recording'** + String get stopRecording; + + /// + /// + /// In en, this message translates to: + /// **'Record voice'** + String get recordVoice; + + /// + /// + /// In en, this message translates to: + /// **'More actions'** + String get moreActions; + + /// + /// + /// In en, this message translates to: + /// **'No messages in this filter'** + String get noMessagesInFilter; + + /// + /// + /// In en, this message translates to: + /// **'No matches in this filter'** + String get noMatchesInFilter; + + /// + /// + /// In en, this message translates to: + /// **'Sync failed: {error}'** + String syncFailed(String error); + + /// + /// + /// In en, this message translates to: + /// **'Invalid link'** + String get invalidLink; + + /// + /// + /// In en, this message translates to: + /// **'Cannot open link'** + String get cannotOpenLink; + + /// + /// + /// In en, this message translates to: + /// **'Failed to open link'** + String get failedToOpenLink; + + /// + /// + /// In en, this message translates to: + /// **'Failed to resend message'** + String get failedToResendMessage; + + /// + /// + /// In en, this message translates to: + /// **'Retry failed: {error}'** + String retryFailed(String error); + + /// + /// + /// In en, this message translates to: + /// **'Delete drawing & message'** + String get deleteDrawingAndMessage; + + /// + /// + /// In en, this message translates to: + /// **'Last echo relay'** + String get lastEchoRelay; + + /// + /// + /// In en, this message translates to: + /// **'Last echo path'** + String get lastEchoPath; + + /// + /// + /// In en, this message translates to: + /// **'Last echo bytes report'** + String get lastEchoBytesReport; + + /// + /// + /// In en, this message translates to: + /// **'No GPS coordinates available'** + String get noGpsCoordinatesAvailable; + + /// + /// + /// In en, this message translates to: + /// **'Not a SAR marker'** + String get notASarMarker; + + /// + /// + /// In en, this message translates to: + /// **'Failed to save template: {error}'** + String failedToSaveTemplate(String error); + + /// + /// + /// In en, this message translates to: + /// **'Drawing not found'** + String get drawingNotFound; + + /// + /// + /// In en, this message translates to: + /// **'Unknown drawing type'** + String get unknownDrawingType; + + /// + /// + /// In en, this message translates to: + /// **'Custom map marker'** + String get customMapMarker; + + /// + /// + /// In en, this message translates to: + /// **'Point: {point}'** + String customMapPointLabel(String point); + + /// + /// + /// In en, this message translates to: + /// **'Map ID: {id}'** + String mapIdLabel(String id); + + /// + /// + /// In en, this message translates to: + /// **'Cannot fetch voice'** + String get cannotFetchVoice; + + /// + /// + /// In en, this message translates to: + /// **'Sender contact is unknown. Sync contacts first.'** + String get senderContactUnknown; + + /// + /// + /// In en, this message translates to: + /// **'Sender route is unknown. Sync contacts/path first.'** + String get senderRouteUnknown; + + /// + /// + /// In en, this message translates to: + /// **'Message is too far ({hops} hops, max {maxHops}).'** + String messageTooFar(String hops, String maxHops); + + /// + /// + /// In en, this message translates to: + /// **'Sender route did not respond to a path check. Sync contacts/path and try again.'** + String get senderRouteNoPathResponse; + + /// + /// + /// In en, this message translates to: + /// **'Sender route did not respond on the raw transport path.'** + String get senderRouteNoRawResponse; + + /// + /// + /// In en, this message translates to: + /// **'Voice fetch over {hops} hops may take a while.'** + String voiceFetchOverHops(String hops); + + /// + /// + /// In en, this message translates to: + /// **'Voice receive canceled'** + String get voiceReceiveCanceled; + + /// + /// + /// In en, this message translates to: + /// **'Fetching missing voice fragments'** + String get fetchingMissingVoiceFragments; + + /// + /// + /// In en, this message translates to: + /// **'Receiving voice'** + String get receivingVoice; + + /// + /// + /// In en, this message translates to: + /// **'Cannot fetch image'** + String get cannotFetchImage; + + /// + /// + /// In en, this message translates to: + /// **'Image fetch over {hops} hops may take a while.'** + String imageFetchOverHops(String hops); + + /// + /// + /// In en, this message translates to: + /// **'Image is already being received'** + String get imageAlreadyBeingReceived; + + /// + /// + /// In en, this message translates to: + /// **'Load image'** + String get loadImage; + + /// + /// + /// In en, this message translates to: + /// **'Image fetch failed to send request'** + String get imageFetchFailedToSendRequest; + + /// + /// + /// In en, this message translates to: + /// **'Image unavailable right now'** + String get imageUnavailable; + + /// + /// + /// In en, this message translates to: + /// **'Image fetch timed out'** + String get imageFetchTimedOut; + + /// + /// + /// In en, this message translates to: + /// **'Image receive canceled'** + String get imageReceiveCanceled; + + /// + /// + /// In en, this message translates to: + /// **'Close image preview'** + String get closeImagePreview; + + /// + /// + /// In en, this message translates to: + /// **'Recording... release to send voice'** + String get recordingReleaseToSend; + + /// + /// + /// In en, this message translates to: + /// **'Sending voice...'** + String get sendingVoice; + + /// + /// + /// In en, this message translates to: + /// **'Send (long press to record voice)'** + String get sendLongPressToRecordVoice; + + /// + /// + /// In en, this message translates to: + /// **'Discard recording'** + String get discardRecording; + + /// + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 hop} other{{count} hops}}'** + String hopCount(num count); + + /// + /// + /// In en, this message translates to: + /// **'Broadcast lanes for nearby mesh traffic'** + String get channelsSectionDescription; + + /// + /// + /// In en, this message translates to: + /// **'Shared spaces for ongoing team coordination'** + String get roomsSectionDescription; + + /// + /// + /// In en, this message translates to: + /// **'Direct people and devices you can reach'** + String get contactsSectionDescription; + + /// + /// + /// In en, this message translates to: + /// **'No recent chatters'** + String get noRecentChatters; + + /// + /// + /// In en, this message translates to: + /// **'Public'** + String get public; + + /// + /// + /// In en, this message translates to: + /// **'Failed to send Tic-Tac-Toe move'** + String get failedToSendTicTacToeMove; + + /// + /// + /// In en, this message translates to: + /// **'You won'** + String get youWon; + + /// + /// + /// In en, this message translates to: + /// **'Opponent won'** + String get opponentWon; + + /// + /// + /// In en, this message translates to: + /// **'Draw'** + String get gameDraw; + + /// + /// + /// In en, this message translates to: + /// **'Your turn'** + String get yourTurn; + + /// + /// + /// In en, this message translates to: + /// **'Opponent turn'** + String get opponentTurn; + + /// + /// + /// In en, this message translates to: + /// **'Please enter valid coordinates'** + String get pleaseEnterValidCoordinates; + + /// + /// + /// In en, this message translates to: + /// **'Location not available'** + String get locationNotAvailable; + + /// + /// + /// In en, this message translates to: + /// **'Custom cave map point'** + String get customCaveMapPoint; + + /// + /// + /// In en, this message translates to: + /// **'Map point'** + String get mapPoint; + + /// + /// + /// In en, this message translates to: + /// **'Fetching missing fragments…'** + String get fetchingMissingImageFragments; + + /// + /// + /// In en, this message translates to: + /// **'Loading…'** + String get loadingImage; + + /// + /// + /// In en, this message translates to: + /// **'Receiving…'** + String get receivingImage; + + /// + /// + /// In en, this message translates to: + /// **'Tap to load'** + String get tapToLoad; + + /// Connection dialog title + /// + /// In en, this message translates to: + /// **'Connect Device'** + String get connectDevice; + + /// Connection dialog subtitle describing available transports + /// + /// In en, this message translates to: + /// **'Choose Bluetooth, WiFi, or Serial transport'** + String get chooseTransportSubtitle; + + /// Bluetooth Low Energy tab label in connection dialog + /// + /// In en, this message translates to: + /// **'BLE'** + String get ble; + + /// Network tab label in connection dialog + /// + /// In en, this message translates to: + /// **'Network'** + String get network; + + /// Serial tab label in connection dialog + /// + /// In en, this message translates to: + /// **'Serial'** + String get serial; + + /// Button label to start scanning for devices + /// + /// In en, this message translates to: + /// **'Scan'** + String get scan; + + /// Empty state shown before the first BLE scan + /// + /// In en, this message translates to: + /// **'Press scan to search for nearby devices'** + String get pressScanToSearchForDevices; + + /// Error when connecting to a BLE device fails + /// + /// In en, this message translates to: + /// **'Failed to connect to {name}'** + String failedToConnectToDevice(String name); + + /// Fallback name for a BLE device without a name + /// + /// In en, this message translates to: + /// **'Unknown Device'** + String get unknownDevice; + + /// Banner shown when cached network scan results are displayed + /// + /// In en, this message translates to: + /// **'Showing cached results. Tap refresh to rescan.'** + String get showingCachedResultsTapRefresh; + + /// Banner shown while scanning the local network + /// + /// In en, this message translates to: + /// **'Scanning local network for MeshCore WiFi devices on port {port}'** + String scanningLocalNetworkOnPort(int port); + + /// Tooltip for the add-server button while a network scan is running + /// + /// In en, this message translates to: + /// **'Cancel scan and add server'** + String get cancelScanAndAddServer; + + /// Tooltip for the add-server button + /// + /// In en, this message translates to: + /// **'Add server'** + String get addServer; + + /// Network scan progress label + /// + /// In en, this message translates to: + /// **'Scanning... {scanned}/{total} IPs'** + String scanningProgressIps(int scanned, String total); + + /// Button to cancel the network scan and enter a server address manually + /// + /// In en, this message translates to: + /// **'Cancel and enter manually'** + String get cancelAndEnterManually; + + /// Empty state for the network servers tab + /// + /// In en, this message translates to: + /// **'No recent or discovered servers yet'** + String get noRecentOrDiscoveredServers; + + /// Section header for recently used servers + /// + /// In en, this message translates to: + /// **'Recently used'** + String get recentlyUsed; + + /// Status label while a connection is being established + /// + /// In en, this message translates to: + /// **'Connecting...'** + String get connecting; + + /// Section header for servers discovered on the local network + /// + /// In en, this message translates to: + /// **'Discovered on this network'** + String get discoveredOnThisNetwork; + + /// Error when a previously discovered server does not respond anymore + /// + /// In en, this message translates to: + /// **'Server at {host}:{port} is no longer available. Please scan again to find active servers.'** + String serverNoLongerAvailable(String host, int port); + + /// Error when a TCP connection attempt fails + /// + /// In en, this message translates to: + /// **'Failed to connect to {host}:{port}'** + String failedToConnectToHost(String host, int port); + + /// Validation error for the IP address field + /// + /// In en, this message translates to: + /// **'Enter a valid IP address'** + String get enterValidIpAddress; + + /// Validation error for the TCP port field + /// + /// In en, this message translates to: + /// **'Enter a valid TCP port'** + String get enterValidTcpPort; + + /// Label for the IP address text field + /// + /// In en, this message translates to: + /// **'IP address'** + String get ipAddress; + + /// Label for the TCP port text field + /// + /// In en, this message translates to: + /// **'TCP port'** + String get tcpPort; + + /// Helper text for the TCP port text field + /// + /// In en, this message translates to: + /// **'Custom server port'** + String get customServerPort; + + /// Chat tab label on the home screen + /// + /// In en, this message translates to: + /// **'Chat'** + String get chat; + + /// Status text while automatically reconnecting to the last device + /// + /// In en, this message translates to: + /// **'Restoring previous link'** + String get restoringPreviousLink; + + /// Status text when no device is connected + /// + /// In en, this message translates to: + /// **'No device connected'** + String get noDeviceConnected; + + /// Composer action that broadcasts this device's contact info (advert) to the mesh + /// + /// In en, this message translates to: + /// **'Send my contact'** + String get sendAdvert; + + /// Title of the sheet choosing how far the contact broadcast travels + /// + /// In en, this message translates to: + /// **'Send my contact'** + String get advertMode; + + /// Subtitle of the contact broadcast mode sheet + /// + /// In en, this message translates to: + /// **'Choose how far your contact info should travel.'** + String get advertModeSubtitle; + + /// Toast after broadcasting contact info via flood (through repeaters) + /// + /// In en, this message translates to: + /// **'Contact sent across the mesh'** + String get floodAdvertSent; + + /// Toast after broadcasting contact info direct (zero-hop, nearby only) + /// + /// In en, this message translates to: + /// **'Contact sent to nearby devices'** + String get directAdvertSent; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => [ + 'de', + 'el', + 'en', + 'es', + 'fr', + 'hr', + 'it', + 'pl', + 'pt', + 'ru', + 'sl', + 'tr', + 'uk', + 'zh', + ].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'de': + return AppLocalizationsDe(); + case 'el': + return AppLocalizationsEl(); + case 'en': + return AppLocalizationsEn(); + case 'es': + return AppLocalizationsEs(); + case 'fr': + return AppLocalizationsFr(); + case 'hr': + return AppLocalizationsHr(); + case 'it': + return AppLocalizationsIt(); + case 'pl': + return AppLocalizationsPl(); + case 'pt': + return AppLocalizationsPt(); + case 'ru': + return AppLocalizationsRu(); + case 'sl': + return AppLocalizationsSl(); + case 'tr': + return AppLocalizationsTr(); + case 'uk': + return AppLocalizationsUk(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart new file mode 100644 index 0000000..e92a037 --- /dev/null +++ b/lib/l10n/app_localizations_de.dart @@ -0,0 +1,3460 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class AppLocalizationsDe extends AppLocalizations { + AppLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Nachrichten'; + + @override + String get contacts => 'Kontakte'; + + @override + String get map => 'Karte'; + + @override + String get settings => 'Einstellungen'; + + @override + String get connect => 'Verbinden'; + + @override + String get disconnect => 'Trennen'; + + @override + String get noDevicesFound => 'Keine Geräte gefunden'; + + @override + String get scanAgain => 'Erneut scannen'; + + @override + String get deviceNotConnected => 'Gerät nicht verbunden'; + + @override + String get locationPermissionDenied => 'Standortberechtigung verweigert'; + + @override + String get locationPermissionPermanentlyDenied => + 'Standortberechtigung dauerhaft verweigert. Bitte in den Einstellungen aktivieren.'; + + @override + String get locationPermissionRequired => + 'Die Standortberechtigung ist für GPS-Tracking und Teamkoordination erforderlich. Sie können sie später in den Einstellungen aktivieren.'; + + @override + String get locationServicesDisabled => + 'Standortdienste sind deaktiviert. Bitte aktivieren Sie sie in den Einstellungen.'; + + @override + String get failedToGetGpsLocation => + 'GPS-Position konnte nicht abgerufen werden'; + + @override + String failedToAdvertise(String error) { + return 'Senden fehlgeschlagen: $error'; + } + + @override + String get cancelReconnection => 'Wiederverbindung abbrechen'; + + @override + String get general => 'Allgemein'; + + @override + String get theme => 'Design'; + + @override + String get chooseTheme => 'Design auswählen'; + + @override + String get light => 'Hell'; + + @override + String get dark => 'Dunkel'; + + @override + String get blueLightTheme => 'Blaues helles Design'; + + @override + String get blueDarkTheme => 'Blaues dunkles Design'; + + @override + String get sarRed => 'SAR Rot'; + + @override + String get alertEmergencyMode => 'Alarm-/Notfallmodus'; + + @override + String get sarGreen => 'SAR Grün'; + + @override + String get safeAllClearMode => 'Sicher/Entwarnung-Modus'; + + @override + String get autoSystem => 'Automatisch (System)'; + + @override + String get followSystemTheme => 'System-Design folgen'; + + @override + String get showRxTxIndicators => 'RX/TX-Indikatoren anzeigen'; + + @override + String get disableMap => 'Karte deaktivieren'; + + @override + String get language => 'Sprache'; + + @override + String get chooseLanguage => 'Sprache auswählen'; + + @override + String get save => 'Speichern'; + + @override + String get cancel => 'Abbrechen'; + + @override + String get close => 'Schließen'; + + @override + String get about => 'Über'; + + @override + String get appVersion => 'App-Version'; + + @override + String get aboutMeshCoreSar => 'Über MeshCore SAR'; + + @override + String get aboutDescription => + 'Eine Such- und Rettungsanwendung für Notfallteams. Funktionen umfassen:\n\n• BLE-Mesh-Netzwerk für Gerät-zu-Gerät-Kommunikation\n• Offline-Karten mit mehreren Ebenenoptionen\n• Echtzeit-Teammitgliederverfolgung\n• SAR-Taktikmarkierungen (Person gefunden, Feuer, Sammelpunkt)\n• Kontaktverwaltung und Nachrichtenübermittlung\n• GPS-Tracking mit Kompass-Kurs\n• Karten-Tile-Caching für Offline-Nutzung'; + + @override + String get technologiesUsed => 'Verwendete Technologien:'; + + @override + String get technologiesList => + '• Flutter für plattformübergreifende Entwicklung\n• BLE (Bluetooth Low Energy) für Mesh-Netzwerk\n• OpenStreetMap für Kartendarstellung\n• Provider für Zustandsverwaltung\n• SharedPreferences für lokale Speicherung'; + + @override + String get moreInfo => 'Mehr Info'; + + @override + String get packageName => 'Paketname'; + + @override + String get sampleData => 'Beispieldaten'; + + @override + String get sampleDataDescription => + 'Laden oder löschen Sie Beispielkontakte, Kanalnachrichten und SAR-Markierungen zum Testen'; + + @override + String get loadSampleData => 'Beispieldaten laden'; + + @override + String get clearAllData => 'Alle Daten löschen'; + + @override + String get clearAllDataConfirmTitle => 'Alle Daten löschen'; + + @override + String get clearAllDataConfirmMessage => + 'Dadurch werden alle Kontakte und SAR-Markierungen gelöscht. Sind Sie sicher?'; + + @override + String get clear => 'Löschen'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return '$teamCount Teammitglieder, $channelCount Kanäle, $sarCount SAR-Markierungen, $messageCount Nachrichten geladen'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Fehler beim Laden der Beispieldaten: $error'; + } + + @override + String get allDataCleared => 'Alle Daten gelöscht'; + + @override + String get failedToStartBackgroundTracking => + 'Hintergrund-Tracking konnte nicht gestartet werden. Überprüfen Sie Berechtigungen und BLE-Verbindung.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Standortübertragung: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'Die Standard-PIN für Geräte ohne Bildschirm ist 123456. Probleme beim Koppeln? Vergessen Sie das Bluetooth-Gerät in den Systemeinstellungen.'; + + @override + String get noMessagesYet => 'Noch keine Nachrichten'; + + @override + String get pullDownToSync => + 'Nach unten ziehen, um Nachrichten zu synchronisieren'; + + @override + String get deleteContact => 'Kontakt löschen'; + + @override + String get delete => 'Löschen'; + + @override + String get viewOnMap => 'Auf Karte anzeigen'; + + @override + String get refresh => 'Aktualisieren'; + + @override + String get resetPath => 'Pfad zurücksetzen (Umleitung)'; + + @override + String copiedToClipboard(String label) { + return '$label in die Zwischenablage kopiert'; + } + + @override + String get pleaseEnterPassword => 'Bitte geben Sie ein Passwort ein'; + + @override + String failedToSyncContacts(String error) { + return 'Kontaktsynchronisation fehlgeschlagen: $error'; + } + + @override + String get loggedInSuccessfully => + 'Erfolgreich angemeldet! Warte auf Raumnachrichten...'; + + @override + String get loginFailed => 'Anmeldung fehlgeschlagen - falsches Passwort'; + + @override + String loggingIn(String roomName) { + return 'Anmeldung bei $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Anmeldung senden fehlgeschlagen: $error'; + } + + @override + String get lowLocationAccuracy => 'Niedrige Standortgenauigkeit'; + + @override + String get continue_ => 'Fortfahren'; + + @override + String get sendSarMarker => 'SAR-Markierung senden'; + + @override + String get deleteDrawing => 'Zeichnung löschen'; + + @override + String get drawingTools => 'Zeichenwerkzeuge'; + + @override + String get drawLine => 'Linie zeichnen'; + + @override + String get drawLineDesc => 'Freihandlinie auf der Karte zeichnen'; + + @override + String get drawRectangle => 'Rechteck zeichnen'; + + @override + String get drawRectangleDesc => 'Rechteckigen Bereich auf der Karte zeichnen'; + + @override + String get measureDistance => 'Entfernung messen'; + + @override + String get measureDistanceDesc => 'Zwei Punkte lang drücken zum Messen'; + + @override + String get clearMeasurement => 'Messung löschen'; + + @override + String distanceLabel(String distance) { + return 'Entfernung: $distance'; + } + + @override + String get longPressForSecondPoint => 'Langer Druck für zweiten Punkt'; + + @override + String get longPressToStartMeasurement => 'Langer Druck für ersten Punkt'; + + @override + String get longPressToStartNewMeasurement => 'Langer Druck für neue Messung'; + + @override + String get shareDrawings => 'Zeichnungen teilen'; + + @override + String get clearAllDrawings => 'Alle Zeichnungen löschen'; + + @override + String get completeLine => 'Linie fertigstellen'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return '$count Zeichnung$plural an Team senden'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Alle $count Zeichnung$plural entfernen'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Alle $count Zeichnung$plural von der Karte löschen?'; + } + + @override + String get drawing => 'Zeichnung'; + + @override + String shareDrawingsCount(int count, String plural) { + return '$count Zeichnung$plural teilen'; + } + + @override + String get showReceivedDrawings => 'Empfangene Zeichnungen anzeigen'; + + @override + String get showingAllDrawings => 'Alle Zeichnungen werden angezeigt'; + + @override + String get showingOnlyYourDrawings => 'Nur Ihre Zeichnungen werden angezeigt'; + + @override + String get showSarMarkers => 'SAR-Markierungen anzeigen'; + + @override + String get showingSarMarkers => 'SAR-Markierungen werden angezeigt'; + + @override + String get hidingSarMarkers => 'SAR-Markierungen ausgeblendet'; + + @override + String get clearAll => 'Alle löschen'; + + @override + String get publicChannel => 'Öffentlicher Kanal'; + + @override + String get broadcastToAll => 'An alle Knoten in der Nähe senden (temporär)'; + + @override + String get storedPermanently => 'Dauerhaft im Raum gespeichert'; + + @override + String get notConnectedToDevice => 'Nicht mit Gerät verbunden'; + + @override + String get typeYourMessage => 'Geben Sie Ihre Nachricht ein...'; + + @override + String get quickLocationMarker => 'Schnelle Standortmarkierung'; + + @override + String get markerType => 'Markierungstyp'; + + @override + String get sendTo => 'Senden an'; + + @override + String get noDestinationsAvailable => 'Keine Ziele verfügbar.'; + + @override + String get selectDestination => 'Ziel auswählen...'; + + @override + String get ephemeralBroadcastInfo => + 'Temporär: Nur Over-the-Air-Übertragung. Nicht gespeichert - Knoten müssen online sein.'; + + @override + String get persistentRoomInfo => + 'Dauerhaft: Unveränderlich im Raum gespeichert. Automatisch synchronisiert und offline gespeichert.'; + + @override + String get location => 'Standort'; + + @override + String get fromMap => 'Von Karte'; + + @override + String get gettingLocation => 'Standort wird abgerufen...'; + + @override + String get locationError => 'Standortfehler'; + + @override + String get retry => 'Wiederholen'; + + @override + String get refreshLocation => 'Standort aktualisieren'; + + @override + String accuracyMeters(int accuracy) { + return 'Genauigkeit: ±${accuracy}m'; + } + + @override + String get notesOptional => 'Notizen (optional)'; + + @override + String get addAdditionalInformation => + 'Zusätzliche Informationen hinzufügen...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'Standortgenauigkeit beträgt ±${accuracy}m. Dies ist möglicherweise nicht genau genug für SAR-Operationen.\n\nTrotzdem fortfahren?'; + } + + @override + String get loginToRoom => 'Bei Raum anmelden'; + + @override + String get enterPasswordInfo => + 'Geben Sie das Passwort ein, um auf diesen Raum zuzugreifen. Das Passwort wird für die zukünftige Verwendung gespeichert.'; + + @override + String get password => 'Passwort'; + + @override + String get enterRoomPassword => 'Raumpasswort eingeben'; + + @override + String get loggingInDots => 'Anmeldung läuft...'; + + @override + String get login => 'Anmelden'; + + @override + String failedToAddRoom(String error) { + return 'Fehler beim Hinzufügen des Raums zum Gerät: $error\n\nDer Raum hat möglicherweise noch nicht gesendet.\nVersuchen Sie zu warten, bis der Raum sendet.'; + } + + @override + String get direct => 'Direkt'; + + @override + String get flood => 'Flut'; + + @override + String get autoSend => 'Automatisch senden'; + + @override + String get autoSendDescription => 'Über aktuellen Pfad senden.'; + + @override + String get sendDirect => 'Direkt senden'; + + @override + String get sendDirectDescription => 'Direkt an diesen Kontakt senden.'; + + @override + String get sendFlood => 'Per Flutung senden'; + + @override + String get sendFloodDescription => 'Über alle Repeater senden.'; + + @override + String get loggedIn => 'Angemeldet'; + + @override + String get noGpsData => 'Keine GPS-Daten'; + + @override + String get distance => 'Entfernung'; + + @override + String directPingTimeout(String name) { + return 'Direkter Ping-Timeout - wiederhole $name mit Flutung...'; + } + + @override + String pingFailed(String name) { + return 'Ping fehlgeschlagen an $name - keine Antwort erhalten'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Sind Sie sicher, dass Sie \"$name\" löschen möchten?\n\nDies entfernt den Kontakt sowohl aus der App als auch vom Begleitfunkgerät.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Fehler beim Entfernen des Kontakts: $error'; + } + + @override + String get type => 'Typ'; + + @override + String get publicKey => 'Öffentlicher Schlüssel'; + + @override + String get lastSeen => 'Zuletzt gesehen'; + + @override + String get yes => 'Ja'; + + @override + String get no => 'Nein'; + + @override + String get permissions => 'Berechtigungen'; + + @override + String get telemetry => 'Telemetrie'; + + @override + String get voltage => 'Spannung'; + + @override + String get battery => 'Batterie'; + + @override + String get temperature => 'Temperatur'; + + @override + String get humidity => 'Luftfeuchtigkeit'; + + @override + String get pressure => 'Druck'; + + @override + String get gpsTelemetry => 'GPS (Telemetrie)'; + + @override + String get reLoginToRoom => 'Erneut bei Raum anmelden'; + + @override + String get heading => 'Kurs'; + + @override + String get elevation => 'Höhe'; + + @override + String get accuracy => 'Genauigkeit'; + + @override + String get bearing => 'Peilung'; + + @override + String get direction => 'Richtung'; + + @override + String get filterMarkers => 'Markierungen filtern'; + + @override + String get filterMarkersTooltip => 'Markierungen filtern'; + + @override + String get contactsFilter => 'Kontakte'; + + @override + String get repeatersFilter => 'Repeater'; + + @override + String get sarMarkers => 'SAR-Markierungen'; + + @override + String get foundPerson => 'Person gefunden'; + + @override + String get fire => 'Feuer'; + + @override + String get stagingArea => 'Sammelpunkt'; + + @override + String get showAll => 'Alle anzeigen'; + + @override + String get locationUnavailable => 'Standort nicht verfügbar'; + + @override + String get ahead => 'voraus'; + + @override + String degreesRight(int degrees) { + return '$degrees° rechts'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° links'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Breite: $latitude Lange: $longitude'; + } + + @override + String get noContactsYet => 'Noch keine Kontakte'; + + @override + String get connectToDeviceToLoadContacts => + 'Mit einem Gerät verbinden, um Kontakte zu laden'; + + @override + String get teamMembers => 'Teammitglieder'; + + @override + String get repeaters => 'Repeater'; + + @override + String get rooms => 'Räume'; + + @override + String get channels => 'Kanäle'; + + @override + String get selectMapLayer => 'Kartenebene auswählen'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'ESRI-Satellit'; + + @override + String get googleHybrid => 'Google Hybrid'; + + @override + String get googleRoadmap => 'Google Straßenkarte'; + + @override + String get googleTerrain => 'Google Gelände'; + + @override + String get dragToPosition => 'Zur Position ziehen'; + + @override + String get createSarMarker => 'SAR-Markierung erstellen'; + + @override + String get compass => 'Kompass'; + + @override + String get navigationAndContacts => 'Navigation & Kontakte'; + + @override + String get sarAlert => 'SAR-ALARM'; + + @override + String get textCopiedToClipboard => 'Text in Zwischenablage kopiert'; + + @override + String get cannotReplySenderMissing => + 'Antwort nicht möglich: Absenderinformationen fehlen'; + + @override + String get cannotReplyContactNotFound => + 'Antwort nicht möglich: Kontakt nicht gefunden'; + + @override + String get copyText => 'Text kopieren'; + + @override + String get saveAsTemplate => 'Als Vorlage speichern'; + + @override + String get templateSaved => 'Vorlage erfolgreich gespeichert'; + + @override + String get templateAlreadyExists => + 'Vorlage mit diesem Emoji existiert bereits'; + + @override + String get deleteMessage => 'Nachricht löschen'; + + @override + String get deleteMessageConfirmation => + 'Möchten Sie diese Nachricht wirklich löschen?'; + + @override + String get shareLocation => 'Standort teilen'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nKoordinaten: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'SAR Standort'; + + @override + String get justNow => 'Gerade eben'; + + @override + String minutesAgo(int minutes) { + return 'vor ${minutes}m'; + } + + @override + String hoursAgo(int hours) { + return 'vor ${hours}h'; + } + + @override + String daysAgo(int days) { + return 'vor ${days}d'; + } + + @override + String secondsAgo(int seconds) { + return 'vor ${seconds}s'; + } + + @override + String get sending => 'Wird gesendet...'; + + @override + String get sent => 'Gesendet'; + + @override + String get delivered => 'Zugestellt'; + + @override + String deliveredWithTime(int time) { + return 'Zugestellt (${time}ms)'; + } + + @override + String get failed => 'Fehlgeschlagen'; + + @override + String get broadcast => 'Rundspruch'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Zugestellt an $delivered/$total Kontakte'; + } + + @override + String get allDelivered => 'Alle zugestellt'; + + @override + String get recipientDetails => 'Empfängerdetails'; + + @override + String get pending => 'Ausstehend'; + + @override + String get sarMarkerFoundPerson => 'Person gefunden'; + + @override + String get sarMarkerFire => 'Feuerstandort'; + + @override + String get sarMarkerStagingArea => 'Sammelpunkt'; + + @override + String get sarMarkerObject => 'Objekt gefunden'; + + @override + String get from => 'Von'; + + @override + String get coordinates => 'Koordinaten'; + + @override + String get tapToViewOnMap => 'Tippen, um auf der Karte anzuzeigen'; + + @override + String get radioSettings => 'Funkeinstellungen'; + + @override + String get frequencyMHz => 'Frequenz (MHz)'; + + @override + String get bandwidth => 'Bandbreite'; + + @override + String get spreadingFactor => 'Spreading-Faktor'; + + @override + String get codingRate => 'Codierungsrate'; + + @override + String get txPowerDbm => 'TX-Leistung (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Max.: $power dBm'; + } + + @override + String get you => 'Du'; + + @override + String exportFailed(String error) { + return 'Export fehlgeschlagen: $error'; + } + + @override + String importFailed(String error) { + return 'Import fehlgeschlagen: $error'; + } + + @override + String get unknown => 'Unbekannt'; + + @override + String get onlineLayers => 'Online-Ebenen'; + + @override + String get locationTrail => 'Standortverlauf'; + + @override + String get showTrailOnMap => 'Verlauf auf Karte anzeigen'; + + @override + String get trailVisible => 'Verlauf ist auf der Karte sichtbar'; + + @override + String get trailHiddenRecording => + 'Verlauf ist ausgeblendet (Aufzeichnung läuft noch)'; + + @override + String get duration => 'Dauer'; + + @override + String get points => 'Punkte'; + + @override + String get clearTrail => 'Verlauf löschen'; + + @override + String get clearTrailQuestion => 'Verlauf löschen?'; + + @override + String get clearTrailConfirmation => + 'Sind Sie sicher, dass Sie den aktuellen Standortverlauf löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.'; + + @override + String get noTrailRecorded => 'Noch kein Verlauf aufgezeichnet'; + + @override + String get startTrackingToRecord => + 'Standort-Tracking starten, um Ihren Verlauf aufzuzeichnen'; + + @override + String get trailControls => 'Verlaufssteuerung'; + + @override + String get model => 'Modell'; + + @override + String get version => 'Version'; + + @override + String get maxContacts => 'Max. Kontakte'; + + @override + String get maxChannels => 'Max. Kanäle'; + + @override + String get publicInfo => 'Öffentliche Informationen'; + + @override + String get useCurrentLocation => 'Aktuellen Standort verwenden'; + + @override + String get noneUnknown => 'Keine/Unbekannt'; + + @override + String get chatNode => 'Chat-Knoten'; + + @override + String get repeater => 'Relais'; + + @override + String get roomChannel => 'Raum/Kanal'; + + @override + String typeNumber(int number) { + return 'Typ $number'; + } + + @override + String failedToSave(String error) { + return 'Fehler beim Speichern: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Fehler beim Abrufen des Standorts: $error'; + } + + @override + String get sarTemplates => 'SAR-Vorlagen'; + + @override + String get addTemplate => 'Vorlage hinzufügen'; + + @override + String get editTemplate => 'Vorlage bearbeiten'; + + @override + String get deleteTemplate => 'Vorlage löschen'; + + @override + String get templateName => 'Vorlagenname'; + + @override + String get templateNameHint => 'z. B. Gefundene Person'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji ist erforderlich'; + + @override + String get nameRequired => 'Name ist erforderlich'; + + @override + String get templateDescription => 'Beschreibung (optional)'; + + @override + String get templateDescriptionHint => 'Zusatzlichen Kontext hinzufugen...'; + + @override + String get templateColor => 'Farbe'; + + @override + String get previewFormat => 'Vorschau (SAR-Nachrichtenformat)'; + + @override + String get importFromClipboard => 'Importieren'; + + @override + String get exportToClipboard => 'Exportieren'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Vorlage \'$name\' loschen?'; + } + + @override + String get templateAdded => 'Vorlage hinzugefugt'; + + @override + String get templateUpdated => 'Vorlage aktualisiert'; + + @override + String get templateDeleted => 'Vorlage geloscht'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Vorlagen importiert', + one: '1 Vorlage importiert', + zero: 'Keine Vorlagen importiert', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Vorlagen in die Zwischenablage exportiert', + one: '1 Vorlage in die Zwischenablage exportiert', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Auf Standard zurücksetzen'; + + @override + String get resetToDefaultsConfirmation => + 'Dadurch werden alle benutzerdefinierten Vorlagen gelöscht und die 4 Standardvorlagen wiederhergestellt. Fortfahren?'; + + @override + String get reset => 'Zurücksetzen'; + + @override + String get resetComplete => 'Vorlagen auf Standard zurückgesetzt'; + + @override + String get noTemplates => 'Keine Vorlagen verfugbar'; + + @override + String get tapAddToCreate => + 'Tippen Sie auf +, um Ihre erste Vorlage zu erstellen'; + + @override + String get ok => 'OK'; + + @override + String get locationPermission => 'Standortberechtigung'; + + @override + String get checking => 'Überprüfen...'; + + @override + String get locationPermissionGrantedAlways => 'Erteilt (Immer)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Erteilt (Während der Nutzung)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Verweigert - Tippen zum Anfragen'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Dauerhaft verweigert - Einstellungen öffnen'; + + @override + String get locationPermissionDialogContent => + 'Die Standortberechtigung wurde dauerhaft verweigert. Bitte aktivieren Sie sie in Ihren Geräteeinstellungen, um GPS-Tracking und Standortfreigabe zu nutzen.'; + + @override + String get openSettings => 'Einstellungen öffnen'; + + @override + String get locationPermissionGranted => 'Standortberechtigung erteilt!'; + + @override + String get locationPermissionRequiredForGps => + 'Die Standortberechtigung ist erforderlich für GPS-Tracking und Standortfreigabe.'; + + @override + String get locationPermissionAlreadyGranted => + 'Die Standortberechtigung wurde bereits erteilt.'; + + @override + String get sarNavyBlue => 'SAR Navy Blau'; + + @override + String get sarNavyBlueDescription => 'Professionell/Einsatzmodus'; + + @override + String get selectRecipient => 'Empfänger auswählen'; + + @override + String get broadcastToAllNearby => 'An alle in der Nähe senden'; + + @override + String get searchRecipients => 'Empfänger suchen...'; + + @override + String get noContactsFound => 'Keine Kontakte gefunden'; + + @override + String get noRoomsFound => 'Keine Räume gefunden'; + + @override + String get noRecipientsAvailable => 'Keine Empfänger verfügbar'; + + @override + String get noChannelsFound => 'Keine Kanäle gefunden'; + + @override + String get newMessage => 'Neue Nachricht'; + + @override + String get channel => 'Kanal'; + + @override + String get samplePoliceLead => 'Polizeiführer'; + + @override + String get sampleDroneOperator => 'Drohnenbediener'; + + @override + String get sampleFirefighterAlpha => 'Feuerwehrmann'; + + @override + String get sampleMedicCharlie => 'Sanitäter'; + + @override + String get sampleCommandDelta => 'Kommando'; + + @override + String get sampleFireEngine => 'Feuerwehrfahrzeug'; + + @override + String get sampleAirSupport => 'Luftunterstützung'; + + @override + String get sampleBaseCoordinator => 'Basiskoordinator'; + + @override + String get channelEmergency => 'Notfall'; + + @override + String get channelCoordination => 'Koordination'; + + @override + String get channelUpdates => 'Aktualisierungen'; + + @override + String get sampleTeamMember => 'Beispiel-Teammitglied'; + + @override + String get sampleScout => 'Beispiel-Späher'; + + @override + String get sampleBase => 'Beispiel-Basis'; + + @override + String get sampleSearcher => 'Beispiel-Sucher'; + + @override + String get sampleObjectBackpack => ' Rucksack gefunden - blaue Farbe'; + + @override + String get sampleObjectVehicle => ' Fahrzeug verlassen - Besitzer prüfen'; + + @override + String get sampleObjectCamping => ' Campingausrüstung entdeckt'; + + @override + String get sampleObjectTrailMarker => + ' Wegmarkierung abseits des Pfades gefunden'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Alle Teams melden'; + + @override + String get sampleMsgWeatherUpdate => + 'Wetterupdate: Klarer Himmel, Temp. 18°C'; + + @override + String get sampleMsgBaseCamp => 'Basislager am Sammelplatz eingerichtet'; + + @override + String get sampleMsgTeamAlpha => 'Team bewegt sich zu Sektor 2'; + + @override + String get sampleMsgRadioCheck => 'Funkcheck - alle Stationen antworten'; + + @override + String get sampleMsgWaterSupply => + 'Wasserversorgung verfügbar an Kontrollpunkt 3'; + + @override + String get sampleMsgTeamBravo => 'Team meldet: Sektor 1 frei'; + + @override + String get sampleMsgEtaRallyPoint => + 'Ankunftszeit am Sammelpunkt: 15 Minuten'; + + @override + String get sampleMsgSupplyDrop => 'Versorgungsabwurf bestätigt für 14:00'; + + @override + String get sampleMsgDroneSurvey => + 'Drohnenüberwachung abgeschlossen - keine Funde'; + + @override + String get sampleMsgTeamCharlie => 'Team fordert Unterstützung an'; + + @override + String get sampleMsgRadioDiscipline => + 'An alle Einheiten: Funkdisziplin wahren'; + + @override + String get sampleMsgUrgentMedical => + 'DRINGEND: Medizinische Hilfe benötigt in Sektor 4'; + + @override + String get sampleMsgAdultMale => ' Erwachsener Mann, bei Bewusstsein'; + + @override + String get sampleMsgFireSpotted => 'Feuer gesichtet - Koordinaten folgen'; + + @override + String get sampleMsgSpreadingRapidly => ' Breitet sich schnell aus!'; + + @override + String get sampleMsgPriorityHelicopter => + 'PRIORITÄT: Brauche Hubschrauberunterstützung'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Medizinisches Team auf dem Weg zu Ihrem Standort'; + + @override + String get sampleMsgEvacHelicopter => + 'Evakuierungshubschrauber ETA 10 Minuten'; + + @override + String get sampleMsgEmergencyResolved => 'Notfall behoben - alles klar'; + + @override + String get sampleMsgEmergencyStagingArea => ' Notfall-Sammelplatz'; + + @override + String get sampleMsgEmergencyServices => + 'Rettungsdienste benachrichtigt und auf dem Weg'; + + @override + String get sampleAlphaTeamLead => 'Team-Leiter'; + + @override + String get sampleBravoScout => 'Späher'; + + @override + String get sampleCharlieMedic => 'Sanitäter'; + + @override + String get sampleDeltaNavigator => 'Navigator'; + + @override + String get sampleEchoSupport => 'Unterstützung'; + + @override + String get sampleBaseCommand => 'Basis-Kommando'; + + @override + String get sampleFieldCoordinator => 'Feldkoordinator'; + + @override + String get sampleMedicalTeam => 'Medizinisches Team'; + + @override + String get mapDrawing => 'Kartenzeichnung'; + + @override + String get navigateToDrawing => 'Zur Zeichnung navigieren'; + + @override + String get copyCoordinates => 'Koordinaten kopieren'; + + @override + String get hideFromMap => 'Von Karte ausblenden'; + + @override + String get lineDrawing => 'Linie'; + + @override + String get rectangleDrawing => 'Rechteck'; + + @override + String get manualCoordinates => 'Manuelle Koordinaten'; + + @override + String get enterCoordinatesManually => 'Koordinaten manuell eingeben'; + + @override + String get latitudeLabel => 'Breitengrad'; + + @override + String get longitudeLabel => 'Längengrad'; + + @override + String get exampleCoordinates => 'Beispiel: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Zeichnung teilen'; + + @override + String get shareWithAllNearbyDevices => + 'Mit allen Geräten in der Nähe teilen'; + + @override + String get shareToRoom => 'In Raum teilen'; + + @override + String get sendToPersistentStorage => 'An persistenten Raum-Speicher senden'; + + @override + String get deleteDrawingConfirm => + 'Möchten Sie diese Zeichnung wirklich löschen?'; + + @override + String get drawingDeleted => 'Zeichnung gelöscht'; + + @override + String yourDrawingsCount(int count) { + return 'Ihre Zeichnungen ($count)'; + } + + @override + String get shared => 'Geteilt'; + + @override + String get line => 'Linie'; + + @override + String get rectangle => 'Rechteck'; + + @override + String get updateAvailable => 'Update Verfügbar'; + + @override + String get currentVersion => 'Aktuell'; + + @override + String get latestVersion => 'Neueste'; + + @override + String get downloadUpdate => 'Herunterladen'; + + @override + String get updateLater => 'Später'; + + @override + String get cadastralParcels => 'Katasterparzellen'; + + @override + String get forestRoads => 'Waldwege'; + + @override + String get wmsOverlays => 'WMS Überlagerungen'; + + @override + String get hikingTrails => 'Wanderwege'; + + @override + String get mainRoads => 'Hauptstraßen'; + + @override + String get houseNumbers => 'Hausnummern'; + + @override + String get fireHazardZones => 'Brandgefährdungszonen'; + + @override + String get historicalFires => 'Historische Brände'; + + @override + String get firebreaks => 'Brandschneisen'; + + @override + String get krasFireZones => 'Kras-Brandzonen'; + + @override + String get placeNames => 'Ortsnamen'; + + @override + String get municipalityBorders => 'Gemeindegrenzen'; + + @override + String get topographicMap => 'Topographische Karte 1:25000'; + + @override + String get recentMessages => 'Aktuelle Nachrichten'; + + @override + String get addChannel => 'Kanal hinzufügen'; + + @override + String get channelName => 'Kanalname'; + + @override + String get channelNameHint => 'z.B. Rettungsteam Alpha'; + + @override + String get channelSecret => 'Kanal-Passwort'; + + @override + String get channelSecretHint => 'Gemeinsames Passwort für diesen Kanal'; + + @override + String get channelSecretHelp => + 'Dieses Passwort muss mit allen Teammitgliedern geteilt werden, die Zugriff auf diesen Kanal benötigen'; + + @override + String get channelTypesInfo => + 'Hash-Kanäle (#team): Passwort automatisch aus dem Namen generiert. Gleicher Name = gleicher Kanal auf allen Geräten.\n\nPrivate Kanäle: Verwenden Sie ein explizites Passwort. Nur diejenigen mit dem Passwort können beitreten.'; + + @override + String get hashChannelInfo => + 'Hash-Kanal: Das Passwort wird automatisch aus dem Kanalnamen generiert. Jeder, der denselben Namen verwendet, wird demselben Kanal beitreten.'; + + @override + String get channelNameRequired => 'Kanalname ist erforderlich'; + + @override + String get channelNameTooLong => + 'Kanalname darf maximal 31 Zeichen lang sein'; + + @override + String get channelSecretRequired => 'Kanal-Passwort ist erforderlich'; + + @override + String get channelSecretTooLong => + 'Kanal-Passwort darf maximal 32 Zeichen lang sein'; + + @override + String get invalidAsciiCharacters => 'Nur ASCII-Zeichen sind erlaubt'; + + @override + String get channelCreatedSuccessfully => 'Kanal erfolgreich erstellt'; + + @override + String channelCreationFailed(String error) { + return 'Kanal konnte nicht erstellt werden: $error'; + } + + @override + String get deleteChannel => 'Kanal löschen'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Sind Sie sicher, dass Sie den Kanal \"$channelName\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.'; + } + + @override + String get channelDeletedSuccessfully => 'Kanal erfolgreich gelöscht'; + + @override + String channelDeletionFailed(String error) { + return 'Kanal konnte nicht gelöscht werden: $error'; + } + + @override + String get createChannel => 'Kanal erstellen'; + + @override + String get wizardBack => 'Zurück'; + + @override + String get wizardSkip => 'Überspringen'; + + @override + String get wizardNext => 'Weiter'; + + @override + String get wizardGetStarted => 'Loslegen'; + + @override + String get wizardWelcomeTitle => 'Willkommen bei MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Willkommens-Tutorial ansehen'; + + @override + String get allTeamContacts => 'Alle Team-Kontakte'; + + @override + String directMessagesInfo(int count) { + return 'Direktnachrichten mit Bestätigungen. An $count Teammitglieder gesendet.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'SAR-Marker an $count Kontakte gesendet'; + } + + @override + String get noContactsAvailable => 'Keine Team-Kontakte verfügbar'; + + @override + String get reply => 'Antworten'; + + @override + String get technicalDetails => 'Technische Details'; + + @override + String get messageTechnicalDetails => 'Technische Nachrichtendetails'; + + @override + String get linkQuality => 'Verbindungsqualität'; + + @override + String get delivery => 'Zustellung'; + + @override + String get status => 'Status'; + + @override + String get expectedAckTag => 'Erwartetes ACK-Tag'; + + @override + String get roundTrip => 'Roundtrip'; + + @override + String get retryAttempt => 'Wiederholungsversuch'; + + @override + String get floodFallback => 'Flood-Fallback'; + + @override + String get identity => 'Identität'; + + @override + String get messageId => 'Nachrichten-ID'; + + @override + String get sender => 'Absender'; + + @override + String get senderKey => 'Absenderschlüssel'; + + @override + String get recipient => 'Empfänger'; + + @override + String get recipientKey => 'Empfängerschlüssel'; + + @override + String get voice => 'Sprachnachricht'; + + @override + String get voiceId => 'Sprach-ID'; + + @override + String get envelope => 'Umschlag'; + + @override + String get sessionProgress => 'Sitzungsfortschritt'; + + @override + String get complete => 'Vollständig'; + + @override + String get rawDump => 'Rohdaten'; + + @override + String get cannotRetryMissingRecipient => + 'Wiederholung nicht möglich: Empfängerinformationen fehlen'; + + @override + String get voiceUnavailable => 'Sprachnachricht momentan nicht verfügbar'; + + @override + String get requestingVoice => 'Sprachnachricht wird angefordert'; + + @override + String get device => 'Gerät'; + + @override + String get change => 'Ändern'; + + @override + String get wizardOverviewDescription => + 'Diese App vereint MeshCore-Nachrichten, SAR-Feldupdates, Karten und Gerätetools an einem Ort.'; + + @override + String get wizardOverviewFeature1 => + 'Sende Direkt-, Raum- und Kanalnachrichten aus dem Haupttab \"Nachrichten\".'; + + @override + String get wizardOverviewFeature2 => + 'Teile SAR-Markierungen, Kartenzeichnungen, Sprachclips und Bilder über das Mesh.'; + + @override + String get wizardOverviewFeature3 => + 'Verbinde dich per BLE oder TCP und verwalte dann das Begleitfunkgerät direkt in der App.'; + + @override + String get wizardMessagingTitle => 'Nachrichten und Feldberichte'; + + @override + String get wizardMessagingDescription => + 'Nachrichten sind hier mehr als nur Klartext. Die App unterstützt bereits mehrere operative Datentypen und Übertragungsabläufe.'; + + @override + String get wizardMessagingFeature1 => + 'Sende Direktnachrichten, Raumbeiträge und Kanalverkehr aus einem einzigen Editor.'; + + @override + String get wizardMessagingFeature2 => + 'Erstelle SAR-Updates und wiederverwendbare SAR-Vorlagen für häufige Feldberichte.'; + + @override + String get wizardMessagingFeature3 => + 'Übertrage Sprachsitzungen und Bilder mit Fortschritts- und Airtime-Anzeigen in der Oberfläche.'; + + @override + String get wizardConnectDeviceTitle => 'Gerät verbinden'; + + @override + String get wizardConnectDeviceDescription => + 'Verbinde dein MeshCore-Funkgerät, wähle einen Namen und wende ein Funkprofil an, bevor du fortfährst.'; + + @override + String get wizardSetupBadge => 'Einrichtung'; + + @override + String get wizardOverviewBadge => 'Überblick'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Verbunden mit $deviceName'; + } + + @override + String get wizardNoDeviceConnected => 'Noch kein Gerät verbunden'; + + @override + String get wizardSkipForNow => 'Vorerst überspringen'; + + @override + String get wizardDeviceNameLabel => 'Gerätename'; + + @override + String get wizardDeviceNameHelp => + 'Dieser Name wird an andere MeshCore-Nutzer ausgesendet.'; + + @override + String get wizardConfigRegionLabel => 'Konfigurationsregion'; + + @override + String get wizardConfigRegionHelp => + 'Verwendet die vollständige offizielle Liste der MeshCore-Voreinstellungen. Standard ist EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Stelle sicher, dass die ausgewählte Voreinstellung zu deinen lokalen Funkvorschriften passt.'; + + @override + String get wizardPresetNote2 => + 'Die Liste entspricht dem offiziellen Preset-Feed des MeshCore-Konfigurationstools.'; + + @override + String get wizardPresetNote3 => + 'EU/UK (Narrow) bleibt standardmäßig für das Onboarding ausgewählt.'; + + @override + String get wizardSaving => 'Wird gespeichert...'; + + @override + String get wizardSaveAndContinue => 'Speichern und fortfahren'; + + @override + String get wizardEnterDeviceName => + 'Gib vor dem Fortfahren einen Gerätenamen ein.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return '$deviceName mit $presetName gespeichert.'; + } + + @override + String get wizardNetworkTitle => 'Kontakte, Räume und Repeater'; + + @override + String get wizardNetworkDescription => + 'Der Kontakte-Tab organisiert das Netzwerk, das du entdeckst, und die Routen, die im Laufe der Zeit gelernt werden.'; + + @override + String get wizardNetworkFeature1 => + 'Prüfe Teammitglieder, Repeater, Räume, Kanäle und ausstehende Adverts in einer Liste.'; + + @override + String get wizardNetworkFeature2 => + 'Nutze Smart Ping, Raum-Login, gelernte Pfade und Pfad-Reset-Tools, wenn die Verbindung unübersichtlich wird.'; + + @override + String get wizardNetworkFeature3 => + 'Erstelle Kanäle und verwalte Netzwerkziele, ohne die App zu verlassen.'; + + @override + String get wizardMapOpsTitle => 'Karte, Spuren und geteilte Geometrie'; + + @override + String get wizardMapOpsDescription => + 'Die Karte der App ist direkt mit Nachrichten, Tracking und SAR-Overlays verbunden, statt nur ein separater Viewer zu sein.'; + + @override + String get wizardMapOpsFeature1 => + 'Verfolge deine eigene Position, Teamstandorte und Bewegungsspuren auf der Karte.'; + + @override + String get wizardMapOpsFeature2 => + 'Öffne Zeichnungen aus Nachrichten, prüfe sie inline und entferne sie bei Bedarf von der Karte.'; + + @override + String get wizardMapOpsFeature3 => + 'Nutze Repeater-Kartenansichten und geteilte Overlays, um die Netzabdeckung im Feld zu verstehen.'; + + @override + String get wizardToolsTitle => 'Werkzeuge jenseits von Nachrichten'; + + @override + String get wizardToolsDescription => + 'Es gibt hier mehr als nur die vier Haupttabs. Die App enthält außerdem Konfiguration, Diagnose und optionale Sensor-Workflows.'; + + @override + String get wizardToolsFeature1 => + 'Öffne die Gerätekonfiguration, um Funkparameter, Telemetrie, Sendeleistung und Begleitdetails zu ändern.'; + + @override + String get wizardToolsFeature2 => + 'Aktiviere den Sensoren-Tab, wenn du überwachte Sensor-Dashboards und schnelle Aktualisierungen möchtest.'; + + @override + String get wizardToolsFeature3 => + 'Nutze Paketprotokolle, Spektrumsuche und Entwicklerdiagnosen zur Fehlersuche im Mesh.'; + + @override + String get postConnectDiscoveryTitle => 'Repeater jetzt entdecken?'; + + @override + String get postConnectDiscoveryDescription => + 'Repeater-Erkennung direkt nach dem Verbinden ausführen, damit Sie nahegelegene MeshCore-Knoten sehen und schneller zu Ihrem Netzwerk hinzufügen können.'; + + @override + String get contactInSensors => 'In Sensoren'; + + @override + String get contactAddToSensors => 'Zu Sensoren hinzufügen'; + + @override + String get contactSetPath => 'Pfad festlegen'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName zu Sensoren hinzugefügt'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Pfad konnte nicht gelöscht werden: $error'; + } + + @override + String get contactRouteCleared => 'Pfad gelöscht'; + + @override + String contactRouteSet(String route) { + return 'Pfad gesetzt: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Pfad konnte nicht gesetzt werden: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'ACK-Zeitüberschreitung'; + + @override + String get opcode => 'Opcode'; + + @override + String get payload => 'Nutzlast'; + + @override + String get hops => 'Hops'; + + @override + String get hashSize => 'Hash-Größe'; + + @override + String get pathBytes => 'Pfad-Bytes'; + + @override + String get selectedPath => 'Ausgewählter Pfad'; + + @override + String get estimatedTx => 'Geschätzte Sendung'; + + @override + String get senderToReceipt => 'Sender bis Empfang'; + + @override + String get receivedCopies => 'Empfangene Kopien'; + + @override + String get retryCause => 'Wiederholungsursache'; + + @override + String get retryMode => 'Wiederholungsmodus'; + + @override + String get retryResult => 'Wiederholungsergebnis'; + + @override + String get lastRetry => 'Letzter Versuch'; + + @override + String get rxPackets => 'RX-Pakete'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Rate'; + + @override + String get window => 'Fenster'; + + @override + String get posttxDelay => 'Sendeverzögerung'; + + @override + String get bandpass => 'Bandpass'; + + @override + String get bandpassFilterVoice => 'Bandpassfilter Sprache'; + + @override + String get active => 'Aktiv'; + + @override + String get addContact => 'Kontakt hinzufügen'; + + @override + String get all => 'Alle'; + + @override + String get clearAllLabel => 'Alle löschen'; + + @override + String get clearFilters => 'Filter löschen'; + + @override + String get clearRoute => 'Route löschen'; + + @override + String get clearMessages => 'Nachrichten löschen'; + + @override + String get clearScale => 'Maßstab löschen'; + + @override + String get clearDiscoveries => 'Entdeckungen löschen'; + + @override + String get clearOnlineTraceDatabase => 'Online-Trace-Datenbank löschen'; + + @override + String get clearAllChannels => 'Alle Kanäle löschen'; + + @override + String get clearAllContacts => 'Alle Kontakte löschen'; + + @override + String get clearChannels => 'Kanäle löschen'; + + @override + String get clearContacts => 'Kontakte löschen'; + + @override + String get clearPathOnMaxRetry => 'Pfad bei max. Wiederholung löschen'; + + @override + String get create => 'Erstellen'; + + @override + String get custom => 'Benutzerdefiniert'; + + @override + String get defaultValue => 'Standard'; + + @override + String get duplicate => 'Duplizieren'; + + @override + String get editName => 'Name bearbeiten'; + + @override + String get open => 'Öffnen'; + + @override + String get paste => 'Einfügen'; + + @override + String get preview => 'Vorschau'; + + @override + String get remove => 'Entfernen'; + + @override + String get rename => 'Umbenennen'; + + @override + String get resolveAll => 'Alle auflösen'; + + @override + String get send => 'Senden'; + + @override + String get sendAnyway => 'Trotzdem senden'; + + @override + String get share => 'Teilen'; + + @override + String get trace => 'Trace'; + + @override + String get discovery => 'Erkennung'; + + @override + String get discoverRepeaters => 'Repeater entdecken'; + + @override + String get discoverSensors => 'Sensoren entdecken'; + + @override + String get repeaterDiscoverySent => 'Repeater-Erkennung gesendet'; + + @override + String get sensorDiscoverySent => 'Sensor-Erkennung gesendet'; + + @override + String get clearedPendingDiscoveries => 'Ausstehende Entdeckungen gelöscht.'; + + @override + String get autoDiscovery => 'Automatische Erkennung'; + + @override + String get enableAutomaticAdding => 'Automatisches Hinzufügen aktivieren'; + + @override + String get autoaddRepeaters => 'Repeater automatisch hinzufügen'; + + @override + String get autoaddRoomServers => 'Raumserver automatisch hinzufügen'; + + @override + String get autoaddSensors => 'Sensoren automatisch hinzufügen'; + + @override + String get autoaddUsers => 'Benutzer automatisch hinzufügen'; + + @override + String get overwriteOldestWhenFull => + 'Älteste bei vollem Speicher überschreiben'; + + @override + String get storage => 'Speicher'; + + @override + String get dangerZone => 'Gefahrenbereich'; + + @override + String get profiles => 'Profile'; + + @override + String get favourites => 'Favoriten'; + + @override + String get sensors => 'Sensoren'; + + @override + String get others => 'Andere'; + + @override + String get gpsModule => 'GPS-Modul'; + + @override + String get liveTraffic => 'Live-Verkehr'; + + @override + String get repeatersMap => 'Repeater-Karte'; + + @override + String get blePacketLogs => 'BLE-Paketprotokolle'; + + @override + String get onlineTraceDatabase => 'Online-Trace-Datenbank'; + + @override + String get routePathByteSize => 'Pfad-Bytegröße'; + + @override + String get messageNotifications => 'Nachrichtenbenachrichtigungen'; + + @override + String get sarAlerts => 'SAR-Alarme'; + + @override + String get discoveryNotifications => 'Erkennungsbenachrichtigungen'; + + @override + String get updateNotifications => 'Updatebenachrichtigungen'; + + @override + String get muteWhileAppIsOpen => 'Stumm bei geöffneter App'; + + @override + String get disableContacts => 'Kontakte deaktivieren'; + + @override + String get enableSensorsTab => 'Sensoren-Tab aktivieren'; + + @override + String get enableProfiles => 'Profile aktivieren'; + + @override + String get nearestRepeaterFallback => 'Nächster Repeater als Fallback'; + + @override + String get deleteAllStoredMessageHistory => + 'Gesamten Nachrichtenverlauf löschen'; + + @override + String get messageFontSize => 'Nachrichtenschriftgröße'; + + @override + String get rotateMapWithHeading => 'Karte mit Richtung drehen'; + + @override + String get showMapDebugInfo => 'Karten-Debug-Info anzeigen'; + + @override + String get openMapInFullscreen => 'Karte im Vollbild öffnen'; + + @override + String get showSarMarkersLabel => 'SAR-Markierungen anzeigen'; + + @override + String get hideRepeatersOnMap => 'Repeater auf Karte ausblenden'; + + @override + String get setMapScale => 'Kartenmaßstab einstellen'; + + @override + String get customMapScaleSaved => + 'Benutzerdefinierter Kartenmaßstab gespeichert'; + + @override + String get voiceBitrate => 'Sprach-Bitrate'; + + @override + String get voiceCompressor => 'Sprachkompressor'; + + @override + String get voiceLimiter => 'Sprach-Limiter'; + + @override + String get micAutoGain => 'Mikrofon-Automatik'; + + @override + String get echoCancellation => 'Echounterdrückung'; + + @override + String get noiseSuppression => 'Rauschunterdrückung'; + + @override + String get trimSilenceInVoiceMessages => + 'Stille in Sprachnachrichten trimmen'; + + @override + String get compressor => 'Kompressor'; + + @override + String get limiter => 'Limiter'; + + @override + String get autoGain => 'Automatische Verstärkung'; + + @override + String get echoCancel => 'Echo'; + + @override + String get noiseSuppress => 'Rauschen'; + + @override + String get silenceTrim => 'Stille'; + + @override + String get maxImageSize => 'Maximale Bildgröße'; + + @override + String get imageCompression => 'Bildkomprimierung'; + + @override + String get grayscale => 'Graustufen'; + + @override + String get ultraMode => 'Ultra-Modus'; + + @override + String get fastPrivateGpsUpdates => 'Schnelle private GPS-Updates'; + + @override + String get movementThreshold => 'Bewegungsschwelle'; + + @override + String get fastGpsMovementThreshold => 'Schneller GPS-Bewegungsschwellenwert'; + + @override + String get fastGpsActiveuseInterval => + 'Schnelles GPS aktive Nutzung Intervall'; + + @override + String get activeuseUpdateInterval => 'Aktive Nutzung Updateintervall'; + + @override + String get repeatNearbyTraffic => 'Nahverkehr wiederholen'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Über Repeater im Mesh weiterleiten'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Nur in der Nähe, ohne Repeater-Flooding'; + + @override + String get multihop => 'Multi-Hop'; + + @override + String get createProfile => 'Profil erstellen'; + + @override + String get renameProfile => 'Profil umbenennen'; + + @override + String get newProfile => 'Neues Profil'; + + @override + String get manageProfiles => 'Profile verwalten'; + + @override + String get enableProfilesToStartManagingThem => + 'Aktivieren Sie Profile, um sie zu verwalten.'; + + @override + String get openMessage => 'Nachricht öffnen'; + + @override + String get jumpToTheRelatedSarMessage => + 'Zur zugehörigen SAR-Nachricht springen'; + + @override + String get removeSarMarker => 'SAR-Markierung entfernen'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Bitte wählen Sie ein Ziel zum Senden der SAR-Markierung'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'SAR-Markierung an öffentlichen Kanal gesendet'; + + @override + String get sarMarkerSentToRoom => 'SAR-Markierung an Raum gesendet'; + + @override + String get loadFromGallery => 'Aus Galerie laden'; + + @override + String get replaceImage => 'Bild ersetzen'; + + @override + String get selectFromGallery => 'Aus Galerie auswählen'; + + @override + String get team => 'Team'; + + @override + String get found => 'Gefunden'; + + @override + String get staging => 'Sammelpunkt'; + + @override + String get object => 'Objekt'; + + @override + String get quiet => 'Ruhig'; + + @override + String get busy => 'Beschäftigt'; + + @override + String get searchMessages => 'Nachrichten suchen'; + + @override + String get sendImageFromGallery => 'Bild aus Galerie senden'; + + @override + String get takePhoto => 'Foto aufnehmen'; + + @override + String get allMessages => 'Alle Nachrichten'; + + @override + String get sendToPublicChannel => 'An öffentlichen Kanal senden?'; + + @override + String get selectMarkerTypeAndDestination => + 'Markierungstyp und Ziel auswählen'; + + @override + String get noDestinationsAvailableLabel => 'Keine Ziele verfügbar'; + + @override + String get image => 'Bild'; + + @override + String get format => 'Format'; + + @override + String get dimensions => 'Abmessungen'; + + @override + String get segments => 'Segmente'; + + @override + String get transfers => 'Übertragungen'; + + @override + String get downloadedBy => 'Heruntergeladen von'; + + @override + String get saveDiscoverySettings => 'Erkennungseinstellungen speichern'; + + @override + String get savePublicInfo => 'Öffentliche Info speichern'; + + @override + String get saveRadioSettings => 'Funkeinstellungen speichern'; + + @override + String get savePath => 'Pfad speichern'; + + @override + String get wipeDeviceData => 'Gerätedaten löschen'; + + @override + String get wipeDevice => 'Gerät löschen'; + + @override + String get destructiveDeviceActions => 'Destruktive Geräteaktionen.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Wählen Sie eine Voreinstellung oder passen Sie die Funkeinstellungen an.'; + + @override + String get used => 'Belegt'; + + @override + String get total => 'Gesamt'; + + @override + String get renameValue => 'Wert umbenennen'; + + @override + String get customizeFields => 'Felder anpassen'; + + @override + String get livePreview => 'Live-Vorschau'; + + @override + String get refreshSchedule => 'Aktualisierungsplan'; + + @override + String get noResponse => 'Keine Antwort'; + + @override + String get refreshing => 'Wird aktualisiert'; + + @override + String get unavailable => 'Nicht verfügbar'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Wählen Sie einen Relay oder Knoten zur Beobachtung.'; + + @override + String get publicKeyLabel => 'Öffentlicher Schlüssel'; + + @override + String get alreadyInContacts => 'Bereits in Kontakten'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Verbinden Sie sich mit einem Gerät, bevor Sie Kontakte hinzufügen'; + + @override + String get fromContacts => 'Aus Kontakten'; + + @override + String get onlineOnly => 'Nur online'; + + @override + String get inBoth => 'In beiden'; + + @override + String get source => 'Quelle'; + + @override + String get allMessagesCleared => 'Alle Nachrichten gelöscht'; + + @override + String get onlineTraceDatabaseCleared => 'Online-Trace-Datenbank gelöscht'; + + @override + String get packetLogsCleared => 'Paketprotokolle gelöscht'; + + @override + String get hexDataCopiedToClipboard => + 'Hex-Daten in die Zwischenablage kopiert'; + + @override + String get developerModeEnabled => 'Entwicklermodus aktiviert'; + + @override + String get developerModeDisabled => 'Entwicklermodus deaktiviert'; + + @override + String get clipboardIsEmpty => 'Zwischenablage ist leer'; + + @override + String get contactImported => 'Kontakt importiert'; + + @override + String get contactLinkCopiedToClipboard => + 'Kontaktlink in Zwischenablage kopiert'; + + @override + String get failedToExportContact => 'Kontaktexport fehlgeschlagen'; + + @override + String get noLogsToExport => 'Keine Protokolle zum Exportieren'; + + @override + String get exportAsCsv => 'Als CSV exportieren'; + + @override + String get exportAsText => 'Als Text exportieren'; + + @override + String get receivedRfc3339 => 'Empfangen (RFC3339)'; + + @override + String get buildTime => 'Build-Zeit'; + + @override + String get downloadUrlNotAvailable => 'Download-URL nicht verfügbar'; + + @override + String get cannotOpenDownloadUrl => 'Download-URL kann nicht geöffnet werden'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'Update-Prüfung nur auf Android verfügbar'; + + @override + String get youAreRunningTheLatestVersion => + 'Sie verwenden die neueste Version'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Update verfügbar, aber Download-URL nicht gefunden'; + + @override + String get startTictactoe => 'Tic-Tac-Toe starten'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe nicht verfügbar'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: Gegner unbekannt'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: Warten auf Start'; + + @override + String get acceptsShareLinks => 'Akzeptiert freigegebene Links'; + + @override + String get supportsRawHex => 'Unterstützt rohes Hex'; + + @override + String get clipboardfriendly => 'Zwischenablage-freundlich'; + + @override + String get captured => 'Erfasst'; + + @override + String get size => 'Größe'; + + @override + String get noCustomChannelsToClear => + 'Keine benutzerdefinierten Kanäle zum Löschen.'; + + @override + String get noDeviceContactsToClear => 'Keine Gerätekontakte zum Löschen.'; + + @override + String get aToZ => 'A-Z'; + + @override + String get add => 'Hinzufügen'; + + @override + String get addAdditionalDetails => 'Zusätzliche Details hinzufügen'; + + @override + String get addPeer => 'Peer hinzufügen'; + + @override + String get addPeerManually => 'Peer manuell hinzufügen'; + + @override + String addedToContacts(String name) { + return '$name zu Kontakten hinzugefügt'; + } + + @override + String addedToSensors(String name) { + return '$name zu Sensoren hinzugefügt'; + } + + @override + String get allowAll => 'Alle zulassen'; + + @override + String get anonymousRxStats => 'Anonyme RX-Statistiken'; + + @override + String get appearance => 'Erscheinungsbild'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Basistelemetrie'; + + @override + String get blePin => 'BLE PIN'; + + @override + String get buzzerAlerts => 'Summer-Alarme'; + + @override + String get cancelImageReceive => 'Bildempfang abbrechen'; + + @override + String get choose => 'Auswählen'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Wähle, welche Tabs und Kontaktbereiche angezeigt werden'; + + @override + String get clearCache => 'Cache leeren'; + + @override + String get clearHistory => 'Verlauf löschen'; + + @override + String get clearLiveView => 'Live-Ansicht leeren'; + + @override + String get clearLogs => 'Protokolle löschen'; + + @override + String get clearOfflineCache => 'Offline-Cache leeren?'; + + @override + String get clearOverlay => 'Overlay löschen'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Route erst nach allen fehlgeschlagenen Versuchen löschen'; + + @override + String get codecBitrateAndAudioProcessing => + 'Codec, Bitrate und Audioverarbeitung'; + + @override + String get connectByIpAddress => 'Über IP-Adresse verbinden'; + + @override + String get controlWhichAlertsYouReceive => + 'Steuere, welche Benachrichtigungen du erhältst'; + + @override + String get copyFullHex => 'Vollständigen Hex kopieren'; + + @override + String get copyRawResponse => 'Rohantwort kopieren'; + + @override + String get couldNotLoadMetHistory => + 'MET-Verlauf konnte nicht geladen werden'; + + @override + String customizeSensor(String name) { + return '$name anpassen'; + } + + @override + String get data => 'Daten'; + + @override + String get deleteGroup => 'Gruppe löschen'; + + @override + String deleteStyleConfirm(String name) { + return '$name löschen?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Diese(s) $type löschen?'; + } + + @override + String get deny => 'Ablehnen'; + + @override + String get deviceInfo => 'Geräteinformationen'; + + @override + String get deviceSettings => 'Geräteeinstellungen'; + + @override + String get deviceTimeSynced => + 'Gerätezeit mit diesem Telefon synchronisiert.'; + + @override + String get disableFastGpsPublishing => + 'Schnelle GPS-Veröffentlichung deaktivieren'; + + @override + String get displayMarkersAndTraceDatabase => + 'Anzeige, Markierungen und Trace-Datenbank'; + + @override + String get download => 'Herunterladen'; + + @override + String get environmentalTelemetry => 'Umwelttelemetrie'; + + @override + String errorCheckingForUpdates(String error) { + return 'Fehler bei der Suche nach Updates: $error'; + } + + @override + String errorGeneric(String error) { + return 'Fehler: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Fehler beim Öffnen des Downloads: $error'; + } + + @override + String get exportLogs => 'Protokolle exportieren'; + + @override + String get extraAggressiveAvifCompression => + 'Extra-aggressive AVIF-Komprimierung'; + + @override + String failedToAddContact(String error) { + return 'Kontakt konnte nicht hinzugefügt werden: $error'; + } + + @override + String failedToAddName(String name, String error) { + return '$name konnte nicht hinzugefügt werden: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Kanäle konnten nicht gelöscht werden: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Kontakte konnten nicht gelöscht werden: $error'; + } + + @override + String get failedToConnectViaSerial => + 'Verbindung über seriell fehlgeschlagen'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Vorschaubild konnte nicht geladen werden: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Trace konnte nicht geladen werden: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'SAR-Markierung konnte nicht gesendet werden: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Summermodus konnte nicht gesetzt werden: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'GPS-Modus konnte nicht gesetzt werden: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Gerätezeit konnte nicht synchronisiert werden: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Gerätedaten konnten nicht gelöscht werden: $error'; + } + + @override + String get fastGpsTargetChannel => 'Schneller GPS-Zielkanal'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Schnelle Updates, Schwellenwerte und Berechtigungen'; + + @override + String get fetchingCatalog => 'Katalog wird geladen...'; + + @override + String get fifteenMinutes => '15 Minuten'; + + @override + String get fifteenSeconds => '15 Sekunden'; + + @override + String get filterByDirection => 'Nach Richtung filtern'; + + @override + String get finish => 'Fertig'; + + @override + String get fiveMinutes => '5 Minuten'; + + @override + String get fiveSeconds => '5 Sekunden'; + + @override + String get fix => 'Fix'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'GPS-Anzeigenrichtlinie'; + + @override + String get gpsAndLocation => 'GPS & Standort'; + + @override + String get gpsDiagnostics => 'GPS-Diagnose'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Hilfe'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Ausgeblendet'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Wie das Funkgerät entdeckte Knoten automatisch hinzufügt.'; + + @override + String get images => 'Bilder'; + + @override + String get importProfile => 'Profil importieren'; + + @override + String get lastFix => 'Letzter Fix'; + + @override + String get latest => 'Neueste'; + + @override + String get latitude => 'Breitengrad'; + + @override + String get loadASavedRegion => 'Gespeicherte Region laden'; + + @override + String get loading => 'Laden'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Standortfreigabe, Hardware und Aktualisierungsintervall.'; + + @override + String get locationTelemetry => 'Standorttelemetrie'; + + @override + String get lockDestination => 'Ziel sperren'; + + @override + String get longitude => 'Längengrad'; + + @override + String get mapStyle => 'Kartenstil'; + + @override + String get max => 'Max'; + + @override + String get maxZoom => 'Max. Zoom'; + + @override + String get messaging => 'Nachrichten'; + + @override + String get metHistory => 'MET-Verlauf'; + + @override + String get min => 'Min'; + + @override + String get minZoom => 'Min. Zoom'; + + @override + String get moveDown => 'Nach unten'; + + @override + String get moveUp => 'Nach oben'; + + @override + String get multiAckMode => 'Multi-ACK-Modus'; + + @override + String get multiDeviceWorkspaceManagement => + 'Mehrgeräte-Arbeitsbereichsverwaltung'; + + @override + String get myLocation => 'Mein Standort'; + + @override + String get nameAndTelemetryShared => + 'Name und Telemetrie werden mit anderen Geräten geteilt.'; + + @override + String newVersionAvailable(String version) { + return 'Neue Version: $version'; + } + + @override + String get newer => 'Neuer'; + + @override + String get noNeighboursFound => 'Keine Nachbarn gefunden'; + + @override + String get noServersFound => 'Keine Server gefunden'; + + @override + String get notSet => 'Nicht gesetzt'; + + @override + String get notifications => 'Benachrichtigungen'; + + @override + String get offZeroSeconds => 'Aus (0 s)'; + + @override + String get offlineMaps => 'Offlinekarten'; + + @override + String get older => 'Älter'; + + @override + String get oneByteMode0 => '1 Byte (Modus 0)'; + + @override + String get oneHour => '1 Stunde'; + + @override + String get oneMinute => '1 Minute'; + + @override + String get openPacketLogs => 'Paketprotokolle öffnen'; + + @override + String get packetTypeHelp => 'Pakettyp-Hilfe'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Teilen-Link oder Hex-Anzeige einfügen'; + + @override + String get pathHash => 'Pfad-Hash'; + + @override + String get pathSize => 'Pfadgröße'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Polygon'; + + @override + String get rawResponseCopied => 'Rohantwort kopiert'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Bei Fehler über nächsten Repeater erneut senden'; + + @override + String get resolveContact => 'Kontakt auflösen'; + + @override + String get routingRetriesAndDestinationLock => + 'Routing, Wiederholungsversuche und Zielsperre'; + + @override + String get samples => 'Proben'; + + @override + String get satellites => 'Satelliten'; + + @override + String get saving => 'Speichern...'; + + @override + String get search => 'Suchen'; + + @override + String get searchByNameOrToken => 'Nach Name oder Token suchen'; + + @override + String get searchDiscoveredNodes => 'Entdeckte Knoten suchen'; + + @override + String get searchInCurrentFilter => 'Im aktuellen Filter suchen'; + + @override + String get searchLogs => 'Protokolle durchsuchen...'; + + @override + String get searchSensors => 'Sensoren suchen'; + + @override + String get searchThisSection => 'Diesen Bereich durchsuchen'; + + @override + String get selectedRelays => 'Ausgewählte Relais'; + + @override + String get sensor => 'Sensor'; + + @override + String serialError(String error) { + return 'Serieller Fehler: $error'; + } + + @override + String setPathForContact(String name) { + return 'Pfad für $name festlegen'; + } + + @override + String get shareLiveGps => 'Live-GPS teilen'; + + @override + String get shareMyTiles => 'Meine Kacheln teilen'; + + @override + String get sharingLocationFromPhone => + 'Standort auf diesem Kanal vom Telefon wird geteilt.'; + + @override + String get sharingLocationFromRadio => + 'Standort auf diesem Kanal vom Funkgerät wird geteilt.'; + + @override + String signalDbm(String rssi) { + return 'Signal $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => 'Größe, Komprimierung und Vorschau'; + + @override + String get simpleMode => 'Einfacher Modus'; + + @override + String get simpleModeDescription => + 'Nur Chat und Karte mit favorisierten Kontakten'; + + @override + String get simpleModeRequiresLockedDestination => + 'Aktivieren Sie zuerst „Ziel sperren“ mit einem Kanal, um den einfachen Modus zu nutzen'; + + @override + String get noFavouriteContacts => 'Keine favorisierten Kontakte'; + + @override + String get skip => 'Überspringen'; + + @override + String get smallerFileSize => 'Kleinere Dateigröße'; + + @override + String get sort => 'Sortieren'; + + @override + String get stoppedSharingLocation => + 'Standortfreigabe auf diesem Kanal beendet.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Benachrichtigungen im Vordergrund unterdrücken'; + + @override + String get tabsAndNavigation => 'Tabs & Navigation'; + + @override + String get templatesAndTutorials => 'Vorlagen und Anleitungen'; + + @override + String get tenMinutes => '10 Minuten'; + + @override + String get tenSeconds => '10 Sekunden'; + + @override + String get testSendUpdate => 'Testupdate senden'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Design, Sprache und Anzeigeeinstellungen'; + + @override + String get thirtyMinutes => '30 Minuten'; + + @override + String get thirtySeconds => '30 Sekunden'; + + @override + String get threeBytesMode2 => '3 Bytes (Modus 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Verkehrsstatistiken, Nachrichtenverlauf und Entwicklertools'; + + @override + String get twoBytesMode1 => '2 Bytes (Modus 1)'; + + @override + String get twoMinutes => '2 Minuten'; + + @override + String get undo => 'Rückgängig'; + + @override + String get updated => 'Aktualisiert'; + + @override + String get useContactFlags => 'Kontakt-Flags verwenden'; + + @override + String get useSavedCoordinates => 'Gespeicherte Koordinaten verwenden'; + + @override + String get view => 'Ansicht'; + + @override + String get viewNeighbours => 'Nachbarn anzeigen'; + + @override + String get viewPublicStats => 'Öffentliche Statistiken anzeigen'; + + @override + String get setRegionScope => 'Regionsbereich festlegen'; + + @override + String get regionScope => 'Regionsbereich'; + + @override + String get regionScopeNone => 'Keiner (global)'; + + @override + String get clearRegionScope => 'Bereich löschen'; + + @override + String get regionScopeWarning => + 'Nur Repeater, die diese Region zulassen, werden weiterleiten.'; + + @override + String get discoverRegions => 'Von Repeatern entdecken'; + + @override + String get discoveringRegions => 'Regionen werden entdeckt...'; + + @override + String get enterRegionName => 'Regionsname (z.B. auckland)'; + + @override + String get noRegionsFound => 'Keine Regionen auf diesem Repeater gefunden.'; + + @override + String regionScopeSet(String name) { + return 'Regionsbereich auf $name gesetzt'; + } + + @override + String get regionScopeCleared => 'Regionsbereich gelöscht'; + + @override + String get selectDestinationFirst => + 'Wählen Sie zuerst einen Kanal, Kontakt oder Raum'; + + @override + String get recipientNoLongerAvailable => 'Empfänger ist nicht mehr verfügbar'; + + @override + String failedToSendMessage(String error) { + return 'Senden fehlgeschlagen: $error'; + } + + @override + String get tictactoeDirectOnly => + 'Tic-Tac-Toe funktioniert nur in Direktnachrichten. Wählen Sie zuerst einen Kontakt.'; + + @override + String get deviceKeyUnavailable => 'Geräteschlüssel ist nicht verfügbar'; + + @override + String get imageCompressionFailed => 'Bildkomprimierung fehlgeschlagen'; + + @override + String get imageFragmentationFailed => 'Bildfragmentierung fehlgeschlagen'; + + @override + String get failedToAnnounceImage => 'Bild konnte nicht angekündigt werden'; + + @override + String get noRecipientSelected => 'Kein Empfänger ausgewählt'; + + @override + String get imageSendFailed => 'Bildversand fehlgeschlagen'; + + @override + String get microphonePermissionRequiredForVoice => + 'Für Sprachnachrichten ist die Mikrofonberechtigung erforderlich'; + + @override + String get recordingDiscarded => 'Aufnahme verworfen'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Sie sind dabei, $mediaType an den öffentlichen Kanal zu senden. Das ist nicht ratsam, da alle im Mesh-Netz es empfangen können. Wählen Sie einen privaten oder getaggten Kanal, sofern dies nicht beabsichtigt ist.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Wählen Sie einen privaten Kanal, um Ihren Standort zu teilen.'; + + @override + String get stopSharingMyLocation => 'Meinen Standort nicht mehr teilen'; + + @override + String get shareMyLocation => 'Meinen Standort teilen'; + + @override + String get stopRecording => 'Aufnahme stoppen'; + + @override + String get recordVoice => 'Sprachnachricht aufnehmen'; + + @override + String get moreActions => 'Weitere Aktionen'; + + @override + String get noMessagesInFilter => 'Keine Nachrichten in diesem Filter'; + + @override + String get noMatchesInFilter => 'Keine Treffer in diesem Filter'; + + @override + String syncFailed(String error) { + return 'Synchronisierung fehlgeschlagen: $error'; + } + + @override + String get invalidLink => 'Ungültiger Link'; + + @override + String get cannotOpenLink => 'Link kann nicht geöffnet werden'; + + @override + String get failedToOpenLink => 'Link konnte nicht geöffnet werden'; + + @override + String get failedToResendMessage => + 'Nachricht konnte nicht erneut gesendet werden'; + + @override + String retryFailed(String error) { + return 'Wiederholung fehlgeschlagen: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Zeichnung & Nachricht löschen'; + + @override + String get lastEchoRelay => 'Letztes Echo – Relais'; + + @override + String get lastEchoPath => 'Letztes Echo – Pfad'; + + @override + String get lastEchoBytesReport => 'Letztes Echo – Byte-Bericht'; + + @override + String get noGpsCoordinatesAvailable => 'Keine GPS-Koordinaten verfügbar'; + + @override + String get notASarMarker => 'Keine SAR-Markierung'; + + @override + String failedToSaveTemplate(String error) { + return 'Vorlage konnte nicht gespeichert werden: $error'; + } + + @override + String get drawingNotFound => 'Zeichnung nicht gefunden'; + + @override + String get unknownDrawingType => 'Unbekannter Zeichnungstyp'; + + @override + String get customMapMarker => 'Eigene Kartenmarkierung'; + + @override + String customMapPointLabel(String point) { + return 'Punkt: $point'; + } + + @override + String mapIdLabel(String id) { + return 'Karten-ID: $id'; + } + + @override + String get cannotFetchVoice => 'Sprachnachricht kann nicht abgerufen werden'; + + @override + String get senderContactUnknown => + 'Absenderkontakt ist unbekannt. Synchronisieren Sie zuerst die Kontakte.'; + + @override + String get senderRouteUnknown => + 'Absenderroute ist unbekannt. Synchronisieren Sie zuerst Kontakte/Pfad.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Nachricht ist zu weit entfernt ($hops Hops, max. $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'Absenderroute hat auf die Pfadprüfung nicht reagiert. Synchronisieren Sie Kontakte/Pfad und versuchen Sie es erneut.'; + + @override + String get senderRouteNoRawResponse => + 'Absenderroute hat auf dem Raw-Transportpfad nicht reagiert.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Sprachabruf über $hops Hops kann eine Weile dauern.'; + } + + @override + String get voiceReceiveCanceled => 'Sprachempfang abgebrochen'; + + @override + String get fetchingMissingVoiceFragments => + 'Fehlende Sprachfragmente werden abgerufen'; + + @override + String get receivingVoice => 'Sprachnachricht wird empfangen'; + + @override + String get cannotFetchImage => 'Bild kann nicht abgerufen werden'; + + @override + String imageFetchOverHops(String hops) { + return 'Bildabruf über $hops Hops kann eine Weile dauern.'; + } + + @override + String get imageAlreadyBeingReceived => 'Bild wird bereits empfangen'; + + @override + String get loadImage => 'Bild laden'; + + @override + String get imageFetchFailedToSendRequest => + 'Bildabruf-Anfrage konnte nicht gesendet werden'; + + @override + String get imageUnavailable => 'Bild derzeit nicht verfügbar'; + + @override + String get imageFetchTimedOut => 'Bildabruf hat das Zeitlimit überschritten'; + + @override + String get imageReceiveCanceled => 'Bildempfang abgebrochen'; + + @override + String get closeImagePreview => 'Bildvorschau schließen'; + + @override + String get recordingReleaseToSend => + 'Aufnahme läuft ... loslassen zum Senden'; + + @override + String get sendingVoice => 'Sprachnachricht wird gesendet ...'; + + @override + String get sendLongPressToRecordVoice => + 'Senden (lange drücken für Sprachaufnahme)'; + + @override + String get discardRecording => 'Aufnahme verwerfen'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Hops', + one: '1 Hop', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Broadcast-Kanäle für Mesh-Verkehr in der Nähe'; + + @override + String get roomsSectionDescription => + 'Gemeinsame Räume für laufende Teamkoordination'; + + @override + String get contactsSectionDescription => + 'Personen und Geräte, die Sie direkt erreichen können'; + + @override + String get noRecentChatters => 'Keine kürzlichen Teilnehmer'; + + @override + String get public => 'Öffentlich'; + + @override + String get failedToSendTicTacToeMove => + 'Tic-Tac-Toe-Zug konnte nicht gesendet werden'; + + @override + String get youWon => 'Sie haben gewonnen'; + + @override + String get opponentWon => 'Gegner hat gewonnen'; + + @override + String get gameDraw => 'Unentschieden'; + + @override + String get yourTurn => 'Sie sind am Zug'; + + @override + String get opponentTurn => 'Gegner ist am Zug'; + + @override + String get pleaseEnterValidCoordinates => + 'Bitte geben Sie gültige Koordinaten ein'; + + @override + String get locationNotAvailable => 'Standort nicht verfügbar'; + + @override + String get customCaveMapPoint => 'Eigener Höhlenkarten-Punkt'; + + @override + String get mapPoint => 'Kartenpunkt'; + + @override + String get fetchingMissingImageFragments => + 'Fehlende Fragmente werden abgerufen …'; + + @override + String get loadingImage => 'Wird geladen …'; + + @override + String get receivingImage => 'Wird empfangen …'; + + @override + String get tapToLoad => 'Zum Laden tippen'; + + @override + String get connectDevice => 'Gerät verbinden'; + + @override + String get chooseTransportSubtitle => + 'Wählen Sie Bluetooth, WiFi oder Seriell als Verbindung'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Netzwerk'; + + @override + String get serial => 'Seriell'; + + @override + String get scan => 'Scannen'; + + @override + String get pressScanToSearchForDevices => + 'Drücken Sie auf Scannen, um nach Geräten in der Nähe zu suchen'; + + @override + String failedToConnectToDevice(String name) { + return 'Verbindung mit $name fehlgeschlagen'; + } + + @override + String get unknownDevice => 'Unbekanntes Gerät'; + + @override + String get showingCachedResultsTapRefresh => + 'Zwischengespeicherte Ergebnisse werden angezeigt. Tippen Sie auf Aktualisieren, um erneut zu scannen.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Lokales Netzwerk wird nach MeshCore-WiFi-Geräten auf Port $port durchsucht'; + } + + @override + String get cancelScanAndAddServer => 'Scan abbrechen und Server hinzufügen'; + + @override + String get addServer => 'Server hinzufügen'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Scannen... $scanned/$total IPs'; + } + + @override + String get cancelAndEnterManually => 'Abbrechen und manuell eingeben'; + + @override + String get noRecentOrDiscoveredServers => + 'Noch keine kürzlich verwendeten oder gefundenen Server'; + + @override + String get recentlyUsed => 'Kürzlich verwendet'; + + @override + String get connecting => 'Verbindung wird hergestellt...'; + + @override + String get discoveredOnThisNetwork => 'In diesem Netzwerk gefunden'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'Server unter $host:$port ist nicht mehr verfügbar. Bitte scannen Sie erneut, um aktive Server zu finden.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Verbindung mit $host:$port fehlgeschlagen'; + } + + @override + String get enterValidIpAddress => 'Geben Sie eine gültige IP-Adresse ein'; + + @override + String get enterValidTcpPort => 'Geben Sie einen gültigen TCP-Port ein'; + + @override + String get ipAddress => 'IP-Adresse'; + + @override + String get tcpPort => 'TCP-Port'; + + @override + String get customServerPort => 'Benutzerdefinierter Server-Port'; + + @override + String get chat => 'Chat'; + + @override + String get restoringPreviousLink => + 'Vorherige Verbindung wird wiederhergestellt'; + + @override + String get noDeviceConnected => 'Kein Gerät verbunden'; + + @override + String get sendAdvert => 'Meinen Kontakt senden'; + + @override + String get advertMode => 'Meinen Kontakt senden'; + + @override + String get advertModeSubtitle => + 'Wählen Sie, wie weit Ihre Kontaktdaten übertragen werden sollen.'; + + @override + String get floodAdvertSent => 'Kontakt über das Mesh-Netzwerk gesendet'; + + @override + String get directAdvertSent => 'Kontakt an Geräte in der Nähe gesendet'; +} diff --git a/lib/l10n/app_localizations_el.dart b/lib/l10n/app_localizations_el.dart new file mode 100644 index 0000000..ab2da00 --- /dev/null +++ b/lib/l10n/app_localizations_el.dart @@ -0,0 +1,3460 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Modern Greek (`el`). +class AppLocalizationsEl extends AppLocalizations { + AppLocalizationsEl([String locale = 'el']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Μηνύματα'; + + @override + String get contacts => 'Επαφές'; + + @override + String get map => 'Χάρτης'; + + @override + String get settings => 'Ρυθμίσεις'; + + @override + String get connect => 'Σύνδεση'; + + @override + String get disconnect => 'Αποσύνδεση'; + + @override + String get noDevicesFound => 'Δεν βρέθηκαν συσκευές'; + + @override + String get scanAgain => 'Σάρωση ξανά'; + + @override + String get deviceNotConnected => 'Η συσκευή δεν είναι συνδεδεμένη'; + + @override + String get locationPermissionDenied => 'Η άδεια τοποθεσίας απορρίφθηκε'; + + @override + String get locationPermissionPermanentlyDenied => + 'Η άδεια τοποθεσίας απορρίφθηκε μόνιμα. Ενεργοποιήστε την στις Ρυθμίσεις.'; + + @override + String get locationPermissionRequired => + 'Απαιτείται άδεια τοποθεσίας για παρακολούθηση GPS και συντονισμό ομάδας. Μπορείτε να την ενεργοποιήσετε αργότερα στις Ρυθμίσεις.'; + + @override + String get locationServicesDisabled => + 'Οι υπηρεσίες τοποθεσίας είναι απενεργοποιημένες. Ενεργοποιήστε τις στις Ρυθμίσεις.'; + + @override + String get failedToGetGpsLocation => 'Αποτυχία λήψης τοποθεσίας GPS'; + + @override + String failedToAdvertise(String error) { + return 'Αποτυχία μετάδοσης: $error'; + } + + @override + String get cancelReconnection => 'Ακύρωση επανασύνδεσης'; + + @override + String get general => 'Γενικά'; + + @override + String get theme => 'Θέμα'; + + @override + String get chooseTheme => 'Επιλογή θέματος'; + + @override + String get light => 'Φωτεινό'; + + @override + String get dark => 'Σκούρο'; + + @override + String get blueLightTheme => 'Μπλε φωτεινό θέμα'; + + @override + String get blueDarkTheme => 'Μπλε σκούρο θέμα'; + + @override + String get sarRed => 'SAR Κόκκινο'; + + @override + String get alertEmergencyMode => 'Λειτουργία συναγερμού/έκτακτης ανάγκης'; + + @override + String get sarGreen => 'SAR Πράσινο'; + + @override + String get safeAllClearMode => 'Λειτουργία ασφαλείας/όλα καθαρά'; + + @override + String get autoSystem => 'Αυτόματο (σύστημα)'; + + @override + String get followSystemTheme => 'Ακολουθεί το θέμα συστήματος'; + + @override + String get showRxTxIndicators => 'Εμφάνιση ενδείξεων RX/TX'; + + @override + String get disableMap => 'Απενεργοποίηση χάρτη'; + + @override + String get language => 'Γλώσσα'; + + @override + String get chooseLanguage => 'Επιλογή γλώσσας'; + + @override + String get save => 'Αποθήκευση'; + + @override + String get cancel => 'Ακύρωση'; + + @override + String get close => 'Κλείσιμο'; + + @override + String get about => 'Σχετικά'; + + @override + String get appVersion => 'Έκδοση εφαρμογής'; + + @override + String get aboutMeshCoreSar => 'Σχετικά με το MeshCore SAR'; + + @override + String get aboutDescription => + 'Εφαρμογή Έρευνας και Διάσωσης σχεδιασμένη για ομάδες απόκρισης έκτακτης ανάγκης. Περιλαμβάνει:\n\n• Δικτύωση BLE mesh για επικοινωνία συσκευή με συσκευή\n• Χάρτες εκτός σύνδεσης με πολλαπλά επίπεδα\n• Παρακολούθηση μελών ομάδας σε πραγματικό χρόνο\n• Τακτικούς δείκτες SAR (εντοπισμένο άτομο, φωτιά, σημείο συγκέντρωσης)\n• Διαχείριση επαφών και μηνυμάτων\n• Παρακολούθηση GPS με πυξίδα κατεύθυνσης\n• Cache πλακιδίων χάρτη για χρήση εκτός σύνδεσης'; + + @override + String get technologiesUsed => 'Χρησιμοποιούμενες τεχνολογίες:'; + + @override + String get technologiesList => + '• Flutter για ανάπτυξη πολλαπλών πλατφορμών\n• BLE (Bluetooth Low Energy) για δικτύωση mesh\n• OpenStreetMap για χαρτογράφηση\n• Provider για διαχείριση κατάστασης\n• SharedPreferences για τοπική αποθήκευση'; + + @override + String get moreInfo => 'Περισσότερες πληροφορίες'; + + @override + String get packageName => 'Όνομα πακέτου'; + + @override + String get sampleData => 'Δείγματα δεδομένων'; + + @override + String get sampleDataDescription => + 'Φόρτωση ή εκκαθάριση δοκιμαστικών επαφών, μηνυμάτων καναλιού και δεικτών SAR για έλεγχο'; + + @override + String get loadSampleData => 'Φόρτωση δειγμάτων δεδομένων'; + + @override + String get clearAllData => 'Εκκαθάριση όλων των δεδομένων'; + + @override + String get clearAllDataConfirmTitle => 'Εκκαθάριση όλων των δεδομένων'; + + @override + String get clearAllDataConfirmMessage => + 'Αυτό θα διαγράψει όλες τις επαφές και τους δείκτες SAR. Είστε βέβαιοι;'; + + @override + String get clear => 'Εκκαθάριση'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Φορτώθηκαν $teamCount μέλη ομάδας, $channelCount κανάλια, $sarCount δείκτες SAR, $messageCount μηνύματα'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Αποτυχία φόρτωσης δειγμάτων δεδομένων: $error'; + } + + @override + String get allDataCleared => 'Όλα τα δεδομένα διαγράφηκαν'; + + @override + String get failedToStartBackgroundTracking => + 'Αποτυχία εκκίνησης παρακολούθησης στο παρασκήνιο. Ελέγξτε τα δικαιώματα και τη σύνδεση BLE.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Μετάδοση τοποθεσίας: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'Το προεπιλεγμένο PIN για συσκευές χωρίς οθόνη είναι 123456. Πρόβλημα στη σύζευξη; Ξεχάστε τη συσκευή Bluetooth από τις ρυθμίσεις συστήματος.'; + + @override + String get noMessagesYet => 'Δεν υπάρχουν ακόμη μηνύματα'; + + @override + String get pullDownToSync => + 'Τραβήξτε προς τα κάτω για συγχρονισμό μηνυμάτων'; + + @override + String get deleteContact => 'Διαγραφή επαφής'; + + @override + String get delete => 'Διαγραφή'; + + @override + String get viewOnMap => 'Προβολή στον χάρτη'; + + @override + String get refresh => 'Ανανέωση'; + + @override + String get resetPath => 'Επαναφορά διαδρομής (νέα δρομολόγηση)'; + + @override + String copiedToClipboard(String label) { + return 'Το $label αντιγράφηκε στο πρόχειρο'; + } + + @override + String get pleaseEnterPassword => 'Παρακαλώ εισαγάγετε κωδικό'; + + @override + String failedToSyncContacts(String error) { + return 'Αποτυχία συγχρονισμού επαφών: $error'; + } + + @override + String get loggedInSuccessfully => + 'Η σύνδεση ολοκληρώθηκε με επιτυχία! Αναμονή για μηνύματα δωματίου...'; + + @override + String get loginFailed => 'Αποτυχία σύνδεσης - λανθασμένος κωδικός'; + + @override + String loggingIn(String roomName) { + return 'Σύνδεση στο $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Αποτυχία αποστολής σύνδεσης: $error'; + } + + @override + String get lowLocationAccuracy => 'Χαμηλή ακρίβεια τοποθεσίας'; + + @override + String get continue_ => 'Συνέχεια'; + + @override + String get sendSarMarker => 'Αποστολή δείκτη SAR'; + + @override + String get deleteDrawing => 'Διαγραφή σχεδίου'; + + @override + String get drawingTools => 'Εργαλεία σχεδίασης'; + + @override + String get drawLine => 'Σχεδίαση γραμμής'; + + @override + String get drawLineDesc => 'Σχεδίαση ελεύθερης γραμμής στον χάρτη'; + + @override + String get drawRectangle => 'Σχεδίαση ορθογωνίου'; + + @override + String get drawRectangleDesc => 'Σχεδίαση ορθογώνιας περιοχής στον χάρτη'; + + @override + String get measureDistance => 'Μέτρηση απόστασης'; + + @override + String get measureDistanceDesc => + 'Παρατεταμένο πάτημα σε δύο σημεία για μέτρηση'; + + @override + String get clearMeasurement => 'Εκκαθάριση μέτρησης'; + + @override + String distanceLabel(String distance) { + return 'Απόσταση: $distance'; + } + + @override + String get longPressForSecondPoint => + 'Παρατεταμένο πάτημα για δεύτερο σημείο'; + + @override + String get longPressToStartMeasurement => + 'Παρατεταμένο πάτημα για ορισμό πρώτου σημείου'; + + @override + String get longPressToStartNewMeasurement => + 'Παρατεταμένο πάτημα για νέα μέτρηση'; + + @override + String get shareDrawings => 'Κοινοποίηση σχεδίων'; + + @override + String get clearAllDrawings => 'Εκκαθάριση όλων των σχεδίων'; + + @override + String get completeLine => 'Ολοκλήρωση γραμμής'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Μετάδοση $count σχεδίου$plural στην ομάδα'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Αφαίρεση όλων των $count σχεδίων$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Διαγραφή όλων των $count σχεδίων$plural από τον χάρτη;'; + } + + @override + String get drawing => 'Σχέδιο'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Κοινοποίηση $count σχεδίου$plural'; + } + + @override + String get showReceivedDrawings => 'Εμφάνιση ληφθέντων σχεδίων'; + + @override + String get showingAllDrawings => 'Εμφανίζονται όλα τα σχέδια'; + + @override + String get showingOnlyYourDrawings => 'Εμφανίζονται μόνο τα δικά σας σχέδια'; + + @override + String get showSarMarkers => 'Εμφάνιση δεικτών SAR'; + + @override + String get showingSarMarkers => 'Εμφανίζονται δείκτες SAR'; + + @override + String get hidingSarMarkers => 'Απόκρυψη δεικτών SAR'; + + @override + String get clearAll => 'Εκκαθάριση όλων'; + + @override + String get publicChannel => 'Δημόσιο κανάλι'; + + @override + String get broadcastToAll => + 'Μετάδοση σε όλους τους κοντινούς κόμβους (προσωρινό)'; + + @override + String get storedPermanently => 'Αποθηκευμένο μόνιμα στο δωμάτιο'; + + @override + String get notConnectedToDevice => 'Δεν υπάρχει σύνδεση με συσκευή'; + + @override + String get typeYourMessage => 'Πληκτρολογήστε το μήνυμά σας...'; + + @override + String get quickLocationMarker => 'Γρήγορος δείκτης τοποθεσίας'; + + @override + String get markerType => 'Τύπος δείκτη'; + + @override + String get sendTo => 'Αποστολή σε'; + + @override + String get noDestinationsAvailable => 'Δεν υπάρχουν διαθέσιμοι προορισμοί.'; + + @override + String get selectDestination => 'Επιλέξτε προορισμό...'; + + @override + String get ephemeralBroadcastInfo => + 'Προσωρινό: Μετάδοση μόνο ασύρματα. Δεν αποθηκεύεται - οι κόμβοι πρέπει να είναι συνδεδεμένοι.'; + + @override + String get persistentRoomInfo => + 'Μόνιμο: Αποθηκεύεται αμετάβλητα στο δωμάτιο. Συγχρονίζεται αυτόματα και διατηρείται εκτός σύνδεσης.'; + + @override + String get location => 'Τοποθεσία'; + + @override + String get fromMap => 'Από χάρτη'; + + @override + String get gettingLocation => 'Λήψη τοποθεσίας...'; + + @override + String get locationError => 'Σφάλμα τοποθεσίας'; + + @override + String get retry => 'Επανάληψη'; + + @override + String get refreshLocation => 'Ανανέωση τοποθεσίας'; + + @override + String accuracyMeters(int accuracy) { + return 'Ακρίβεια: ±$accuracyμ'; + } + + @override + String get notesOptional => 'Σημειώσεις (προαιρετικό)'; + + @override + String get addAdditionalInformation => 'Προσθέστε επιπλέον πληροφορίες...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'Η ακρίβεια τοποθεσίας είναι ±$accuracyμ. Αυτό ίσως δεν είναι αρκετά ακριβές για επιχειρήσεις SAR.\n\nΣυνέχεια ούτως ή άλλως;'; + } + + @override + String get loginToRoom => 'Σύνδεση στο δωμάτιο'; + + @override + String get enterPasswordInfo => + 'Εισαγάγετε τον κωδικό για πρόσβαση σε αυτό το δωμάτιο. Ο κωδικός θα αποθηκευτεί για μελλοντική χρήση.'; + + @override + String get password => 'Κωδικός πρόσβασης'; + + @override + String get enterRoomPassword => 'Εισαγάγετε κωδικό δωματίου'; + + @override + String get loggingInDots => 'Σύνδεση...'; + + @override + String get login => 'Σύνδεση'; + + @override + String failedToAddRoom(String error) { + return 'Αποτυχία προσθήκης δωματίου στη συσκευή: $error\n\nΤο δωμάτιο ίσως δεν έχει μεταδοθεί ακόμη.\nΠεριμένετε να αρχίσει να εκπέμπει.'; + } + + @override + String get direct => 'Άμεσο'; + + @override + String get flood => 'Πλημμυρικό'; + + @override + String get autoSend => 'Αυτόματη αποστολή'; + + @override + String get autoSendDescription => 'Αποστολή μέσω τρέχουσας διαδρομής.'; + + @override + String get sendDirect => 'Άμεση αποστολή'; + + @override + String get sendDirectDescription => 'Αποστολή απευθείας σε αυτήν την επαφή.'; + + @override + String get sendFlood => 'Αποστολή πλημμύρας'; + + @override + String get sendFloodDescription => 'Αποστολή μέσω όλων των αναμεταδοτών.'; + + @override + String get loggedIn => 'Συνδεδεμένος'; + + @override + String get noGpsData => 'Δεν υπάρχουν δεδομένα GPS'; + + @override + String get distance => 'Απόσταση'; + + @override + String directPingTimeout(String name) { + return 'Λήξη χρόνου άμεσου ping - επανάληψη προς $name με flooding...'; + } + + @override + String pingFailed(String name) { + return 'Αποτυχία ping προς $name - δεν ελήφθη απάντηση'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Είστε βέβαιοι ότι θέλετε να διαγράψετε το \"$name\";\n\nΑυτό θα αφαιρέσει την επαφή τόσο από την εφαρμογή όσο και από τη συνοδευτική συσκευή radio.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Αποτυχία αφαίρεσης επαφής: $error'; + } + + @override + String get type => 'Τύπος'; + + @override + String get publicKey => 'Δημόσιο κλειδί'; + + @override + String get lastSeen => 'Τελευταία εμφάνιση'; + + @override + String get yes => 'Ναι'; + + @override + String get no => 'Όχι'; + + @override + String get permissions => 'Δικαιώματα'; + + @override + String get telemetry => 'Τηλεμετρία'; + + @override + String get voltage => 'Τάση'; + + @override + String get battery => 'Μπαταρία'; + + @override + String get temperature => 'Θερμοκρασία'; + + @override + String get humidity => 'Υγρασία'; + + @override + String get pressure => 'Πίεση'; + + @override + String get gpsTelemetry => 'GPS (τηλεμετρία)'; + + @override + String get reLoginToRoom => 'Επανασύνδεση στο δωμάτιο'; + + @override + String get heading => 'Κατεύθυνση'; + + @override + String get elevation => 'Υψόμετρο'; + + @override + String get accuracy => 'Ακρίβεια'; + + @override + String get bearing => 'Πορεία'; + + @override + String get direction => 'Κατεύθυνση'; + + @override + String get filterMarkers => 'Φιλτράρισμα δεικτών'; + + @override + String get filterMarkersTooltip => 'Φιλτράρισμα δεικτών'; + + @override + String get contactsFilter => 'Επαφές'; + + @override + String get repeatersFilter => 'Αναμεταδότες'; + + @override + String get sarMarkers => 'Δείκτες SAR'; + + @override + String get foundPerson => 'Εντοπισμένο άτομο'; + + @override + String get fire => 'Φωτιά'; + + @override + String get stagingArea => 'Σημείο συγκέντρωσης'; + + @override + String get showAll => 'Εμφάνιση όλων'; + + @override + String get locationUnavailable => 'Η τοποθεσία δεν είναι διαθέσιμη'; + + @override + String get ahead => 'μπροστά'; + + @override + String degreesRight(int degrees) { + return '$degrees° δεξιά'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° αριστερά'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Γεωγρ. πλάτος: $latitude Γεωγρ. μήκος: $longitude'; + } + + @override + String get noContactsYet => 'Δεν υπάρχουν ακόμη επαφές'; + + @override + String get connectToDeviceToLoadContacts => + 'Συνδεθείτε σε συσκευή για φόρτωση επαφών'; + + @override + String get teamMembers => 'Μέλη ομάδας'; + + @override + String get repeaters => 'Αναμεταδότες'; + + @override + String get rooms => 'Δωμάτια'; + + @override + String get channels => 'Κανάλια'; + + @override + String get selectMapLayer => 'Επιλογή επιπέδου χάρτη'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'Δορυφορικός ESRI'; + + @override + String get googleHybrid => 'Υβριδικός Google'; + + @override + String get googleRoadmap => 'Οδικός χάρτης Google'; + + @override + String get googleTerrain => 'Ανάγλυφο Google'; + + @override + String get dragToPosition => 'Σύρετε στη θέση'; + + @override + String get createSarMarker => 'Δημιουργία δείκτη SAR'; + + @override + String get compass => 'Πυξίδα'; + + @override + String get navigationAndContacts => 'Πλοήγηση και επαφές'; + + @override + String get sarAlert => 'ΣΥΝΑΓΕΡΜΟΣ SAR'; + + @override + String get textCopiedToClipboard => 'Το κείμενο αντιγράφηκε στο πρόχειρο'; + + @override + String get cannotReplySenderMissing => + 'Αδυναμία απάντησης: λείπουν πληροφορίες αποστολέα'; + + @override + String get cannotReplyContactNotFound => + 'Αδυναμία απάντησης: η επαφή δεν βρέθηκε'; + + @override + String get copyText => 'Αντιγραφή κειμένου'; + + @override + String get saveAsTemplate => 'Αποθήκευση ως πρότυπο'; + + @override + String get templateSaved => 'Το πρότυπο αποθηκεύτηκε επιτυχώς'; + + @override + String get templateAlreadyExists => 'Υπάρχει ήδη πρότυπο με αυτό το emoji'; + + @override + String get deleteMessage => 'Διαγραφή μηνύματος'; + + @override + String get deleteMessageConfirmation => + 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το μήνυμα;'; + + @override + String get shareLocation => 'Κοινοποίηση τοποθεσίας'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nΣυντεταγμένες: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'Τοποθεσία SAR'; + + @override + String get justNow => 'Μόλις τώρα'; + + @override + String minutesAgo(int minutes) { + return 'πριν από $minutesλ'; + } + + @override + String hoursAgo(int hours) { + return 'πριν από $hoursω'; + } + + @override + String daysAgo(int days) { + return 'πριν από $daysη'; + } + + @override + String secondsAgo(int seconds) { + return 'πριν από $secondsδ'; + } + + @override + String get sending => 'Αποστολή...'; + + @override + String get sent => 'Στάλθηκε'; + + @override + String get delivered => 'Παραδόθηκε'; + + @override + String deliveredWithTime(int time) { + return 'Παραδόθηκε (${time}ms)'; + } + + @override + String get failed => 'Απέτυχε'; + + @override + String get broadcast => 'Μετάδοση'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Παραδόθηκε σε $delivered/$total επαφές'; + } + + @override + String get allDelivered => 'Όλα παραδόθηκαν'; + + @override + String get recipientDetails => 'Λεπτομέρειες παραλήπτη'; + + @override + String get pending => 'Σε εκκρεμότητα'; + + @override + String get sarMarkerFoundPerson => 'Εντοπισμένο άτομο'; + + @override + String get sarMarkerFire => 'Σημείο φωτιάς'; + + @override + String get sarMarkerStagingArea => 'Σημείο συγκέντρωσης'; + + @override + String get sarMarkerObject => 'Εντοπισμένο αντικείμενο'; + + @override + String get from => 'Από'; + + @override + String get coordinates => 'Συντεταγμένες'; + + @override + String get tapToViewOnMap => 'Πατήστε για προβολή στον χάρτη'; + + @override + String get radioSettings => 'Ρυθμίσεις radio'; + + @override + String get frequencyMHz => 'Συχνότητα (MHz)'; + + @override + String get bandwidth => 'Εύρος ζώνης'; + + @override + String get spreadingFactor => 'Συντελεστής εξάπλωσης'; + + @override + String get codingRate => 'Ρυθμός κωδικοποίησης'; + + @override + String get txPowerDbm => 'Ισχύς TX (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Μέγ.: $power dBm'; + } + + @override + String get you => 'Εσείς'; + + @override + String exportFailed(String error) { + return 'Η εξαγωγή απέτυχε: $error'; + } + + @override + String importFailed(String error) { + return 'Η εισαγωγή απέτυχε: $error'; + } + + @override + String get unknown => 'Άγνωστο'; + + @override + String get onlineLayers => 'Επίπεδα online'; + + @override + String get locationTrail => 'Ίχνος τοποθεσίας'; + + @override + String get showTrailOnMap => 'Εμφάνιση ίχνους στον χάρτη'; + + @override + String get trailVisible => 'Το ίχνος είναι ορατό στον χάρτη'; + + @override + String get trailHiddenRecording => + 'Το ίχνος είναι κρυφό (συνεχίζεται η καταγραφή)'; + + @override + String get duration => 'Διάρκεια'; + + @override + String get points => 'Σημεία'; + + @override + String get clearTrail => 'Εκκαθάριση ίχνους'; + + @override + String get clearTrailQuestion => 'Εκκαθάριση ίχνους;'; + + @override + String get clearTrailConfirmation => + 'Είστε βέβαιοι ότι θέλετε να διαγράψετε το τρέχον ίχνος τοποθεσίας; Αυτή η ενέργεια δεν αναιρείται.'; + + @override + String get noTrailRecorded => 'Δεν έχει καταγραφεί ακόμη ίχνος'; + + @override + String get startTrackingToRecord => + 'Ξεκινήστε παρακολούθηση τοποθεσίας για καταγραφή ίχνους'; + + @override + String get trailControls => 'Στοιχεία ελέγχου ίχνους'; + + @override + String get model => 'Μοντέλο'; + + @override + String get version => 'Έκδοση'; + + @override + String get maxContacts => 'Μέγιστες επαφές'; + + @override + String get maxChannels => 'Μέγιστα κανάλια'; + + @override + String get publicInfo => 'Δημόσιες πληροφορίες'; + + @override + String get useCurrentLocation => 'Χρήση τρέχουσας τοποθεσίας'; + + @override + String get noneUnknown => 'Κανένα/Άγνωστο'; + + @override + String get chatNode => 'Κόμβος συνομιλίας'; + + @override + String get repeater => 'Αναμεταδότης'; + + @override + String get roomChannel => 'Δωμάτιο/Κανάλι'; + + @override + String typeNumber(int number) { + return 'Τύπος $number'; + } + + @override + String failedToSave(String error) { + return 'Αποτυχία αποθήκευσης: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Αποτυχία λήψης τοποθεσίας: $error'; + } + + @override + String get sarTemplates => 'Πρότυπα SAR'; + + @override + String get addTemplate => 'Προσθήκη προτύπου'; + + @override + String get editTemplate => 'Επεξεργασία προτύπου'; + + @override + String get deleteTemplate => 'Διαγραφή προτύπου'; + + @override + String get templateName => 'Όνομα προτύπου'; + + @override + String get templateNameHint => 'π.χ. Εντοπισμένο άτομο'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Το emoji είναι υποχρεωτικό'; + + @override + String get nameRequired => 'Το όνομα είναι υποχρεωτικό'; + + @override + String get templateDescription => 'Περιγραφή (προαιρετική)'; + + @override + String get templateDescriptionHint => 'Προσθέστε επιπλέον πληροφορίες...'; + + @override + String get templateColor => 'Χρώμα'; + + @override + String get previewFormat => 'Προεπισκόπηση (μορφή μηνύματος SAR)'; + + @override + String get importFromClipboard => 'Εισαγωγή'; + + @override + String get exportToClipboard => 'Εξαγωγή'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Διαγραφή προτύπου «$name»;'; + } + + @override + String get templateAdded => 'Το πρότυπο προστέθηκε'; + + @override + String get templateUpdated => 'Το πρότυπο ενημερώθηκε'; + + @override + String get templateDeleted => 'Το πρότυπο διαγράφηκε'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Εισήχθησαν $count πρότυπα', + one: 'Εισήχθη 1 πρότυπο', + zero: 'Δεν εισήχθησαν πρότυπα', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Εξήχθησαν $count πρότυπα στο πρόχειρο', + one: 'Εξήχθη 1 πρότυπο στο πρόχειρο', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Επαναφορά προεπιλογών'; + + @override + String get resetToDefaultsConfirmation => + 'Αυτό θα διαγράψει όλα τα προσαρμοσμένα πρότυπα και θα επαναφέρει τα 4 προεπιλεγμένα πρότυπα. Συνέχεια;'; + + @override + String get reset => 'Επαναφορά'; + + @override + String get resetComplete => 'Τα πρότυπα επανήλθαν στις προεπιλογές'; + + @override + String get noTemplates => 'Δεν υπάρχουν διαθέσιμα πρότυπα'; + + @override + String get tapAddToCreate => + 'Πατήστε + για να δημιουργήσετε το πρώτο σας πρότυπο'; + + @override + String get ok => 'ΟΚ'; + + @override + String get locationPermission => 'Άδεια τοποθεσίας'; + + @override + String get checking => 'Έλεγχος...'; + + @override + String get locationPermissionGrantedAlways => 'Δόθηκε (πάντα)'; + + @override + String get locationPermissionGrantedWhileInUse => 'Δόθηκε (κατά τη χρήση)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Απορρίφθηκε - πατήστε για αίτημα'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Μόνιμα απορριφθείσα - άνοιγμα ρυθμίσεων'; + + @override + String get locationPermissionDialogContent => + 'Η άδεια τοποθεσίας έχει απορριφθεί μόνιμα. Ενεργοποιήστε την από τις ρυθμίσεις της συσκευής σας για να χρησιμοποιήσετε παρακολούθηση GPS και κοινοποίηση τοποθεσίας.'; + + @override + String get openSettings => 'Άνοιγμα ρυθμίσεων'; + + @override + String get locationPermissionGranted => 'Η άδεια τοποθεσίας δόθηκε!'; + + @override + String get locationPermissionRequiredForGps => + 'Απαιτείται άδεια τοποθεσίας για παρακολούθηση GPS και κοινοποίηση τοποθεσίας.'; + + @override + String get locationPermissionAlreadyGranted => + 'Η άδεια τοποθεσίας έχει ήδη δοθεί.'; + + @override + String get sarNavyBlue => 'SAR Ναυτικό μπλε'; + + @override + String get sarNavyBlueDescription => 'Επαγγελματική/Επιχειρησιακή λειτουργία'; + + @override + String get selectRecipient => 'Επιλογή παραλήπτη'; + + @override + String get broadcastToAllNearby => 'Μετάδοση σε όλους τους κοντινούς'; + + @override + String get searchRecipients => 'Αναζήτηση παραληπτών...'; + + @override + String get noContactsFound => 'Δεν βρέθηκαν επαφές'; + + @override + String get noRoomsFound => 'Δεν βρέθηκαν δωμάτια'; + + @override + String get noRecipientsAvailable => 'Δεν υπάρχουν διαθέσιμοι παραλήπτες'; + + @override + String get noChannelsFound => 'Δεν βρέθηκαν κανάλια'; + + @override + String get newMessage => 'Νέο μήνυμα'; + + @override + String get channel => 'Κανάλι'; + + @override + String get samplePoliceLead => 'Επικεφαλής αστυνομίας'; + + @override + String get sampleDroneOperator => 'Χειριστής drone'; + + @override + String get sampleFirefighterAlpha => 'Πυροσβέστης'; + + @override + String get sampleMedicCharlie => 'Διασώστης'; + + @override + String get sampleCommandDelta => 'Διοίκηση'; + + @override + String get sampleFireEngine => 'Πυροσβεστικό όχημα'; + + @override + String get sampleAirSupport => 'Εναέρια υποστήριξη'; + + @override + String get sampleBaseCoordinator => 'Συντονιστής βάσης'; + + @override + String get channelEmergency => 'Έκτακτη ανάγκη'; + + @override + String get channelCoordination => 'Συντονισμός'; + + @override + String get channelUpdates => 'Ενημερώσεις'; + + @override + String get sampleTeamMember => 'Δοκιμαστικό μέλος ομάδας'; + + @override + String get sampleScout => 'Δοκιμαστικός ανιχνευτής'; + + @override + String get sampleBase => 'Δοκιμαστική βάση'; + + @override + String get sampleSearcher => 'Δοκιμαστικός ερευνητής'; + + @override + String get sampleObjectBackpack => ' Βρέθηκε σακίδιο - μπλε χρώμα'; + + @override + String get sampleObjectVehicle => + ' Εγκαταλελειμμένο όχημα - έλεγχος για ιδιοκτήτη'; + + @override + String get sampleObjectCamping => ' Εντοπίστηκε εξοπλισμός κατασκήνωσης'; + + @override + String get sampleObjectTrailMarker => + ' Βρέθηκε σημάδι μονοπατιού εκτός διαδρομής'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Όλες οι ομάδες, κάντε check-in'; + + @override + String get sampleMsgWeatherUpdate => + 'Ενημέρωση καιρού: Αίθριος ουρανός, θερμ. 18°C'; + + @override + String get sampleMsgBaseCamp => + 'Η βάση εγκαταστάθηκε στο σημείο συγκέντρωσης'; + + @override + String get sampleMsgTeamAlpha => 'Η ομάδα κινείται προς τον τομέα 2'; + + @override + String get sampleMsgRadioCheck => + 'Έλεγχος ασυρμάτου - όλοι οι σταθμοί να απαντήσουν'; + + @override + String get sampleMsgWaterSupply => 'Διαθέσιμο νερό στο σημείο ελέγχου 3'; + + @override + String get sampleMsgTeamBravo => 'Αναφορά ομάδας: ο τομέας 1 είναι καθαρός'; + + @override + String get sampleMsgEtaRallyPoint => + 'Εκτιμώμενη άφιξη στο σημείο συγκέντρωσης: 15 λεπτά'; + + @override + String get sampleMsgSupplyDrop => + 'Η ρίψη εφοδίων επιβεβαιώθηκε για τις 14:00'; + + @override + String get sampleMsgDroneSurvey => + 'Η έρευνα με drone ολοκληρώθηκε - δεν βρέθηκε κάτι'; + + @override + String get sampleMsgTeamCharlie => 'Η ομάδα ζητά ενισχύσεις'; + + @override + String get sampleMsgRadioDiscipline => + 'Όλες οι μονάδες: τηρείτε πειθαρχία ασυρμάτου'; + + @override + String get sampleMsgUrgentMedical => + 'ΕΠΕΙΓΟΝ: Απαιτείται ιατρική βοήθεια στον τομέα 4'; + + @override + String get sampleMsgAdultMale => ' Ενήλικος άνδρας, σε συνείδηση'; + + @override + String get sampleMsgFireSpotted => + 'Εντοπίστηκε φωτιά - ακολουθούν συντεταγμένες'; + + @override + String get sampleMsgSpreadingRapidly => ' Εξαπλώνεται γρήγορα!'; + + @override + String get sampleMsgPriorityHelicopter => + 'ΠΡΟΤΕΡΑΙΟΤΗΤΑ: Απαιτείται υποστήριξη ελικοπτέρου'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Η ιατρική ομάδα κατευθύνεται στη θέση σας'; + + @override + String get sampleMsgEvacHelicopter => 'Ελικόπτερο εκκένωσης σε 10 λεπτά'; + + @override + String get sampleMsgEmergencyResolved => + 'Το περιστατικό επιλύθηκε - όλα καθαρά'; + + @override + String get sampleMsgEmergencyStagingArea => + ' Χώρος συγκέντρωσης έκτακτης ανάγκης'; + + @override + String get sampleMsgEmergencyServices => + 'Οι υπηρεσίες έκτακτης ανάγκης ειδοποιήθηκαν και ανταποκρίνονται'; + + @override + String get sampleAlphaTeamLead => 'Αρχηγός ομάδας'; + + @override + String get sampleBravoScout => 'Ανιχνευτής'; + + @override + String get sampleCharlieMedic => 'Διασώστης'; + + @override + String get sampleDeltaNavigator => 'Πλοηγός'; + + @override + String get sampleEchoSupport => 'Υποστήριξη'; + + @override + String get sampleBaseCommand => 'Διοίκηση βάσης'; + + @override + String get sampleFieldCoordinator => 'Συντονιστής πεδίου'; + + @override + String get sampleMedicalTeam => 'Ιατρική ομάδα'; + + @override + String get mapDrawing => 'Σχέδιο χάρτη'; + + @override + String get navigateToDrawing => 'Πλοήγηση προς το σχέδιο'; + + @override + String get copyCoordinates => 'Αντιγραφή συντεταγμένων'; + + @override + String get hideFromMap => 'Απόκρυψη από χάρτη'; + + @override + String get lineDrawing => 'Σχέδιο γραμμής'; + + @override + String get rectangleDrawing => 'Σχέδιο ορθογωνίου'; + + @override + String get manualCoordinates => 'Χειροκίνητες συντεταγμένες'; + + @override + String get enterCoordinatesManually => 'Εισαγωγή συντεταγμένων χειροκίνητα'; + + @override + String get latitudeLabel => 'Γεωγραφικό πλάτος'; + + @override + String get longitudeLabel => 'Γεωγραφικό μήκος'; + + @override + String get exampleCoordinates => 'Παράδειγμα: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Κοινοποίηση σχεδίου'; + + @override + String get shareWithAllNearbyDevices => + 'Κοινοποίηση σε όλες τις κοντινές συσκευές'; + + @override + String get shareToRoom => 'Κοινοποίηση στο δωμάτιο'; + + @override + String get sendToPersistentStorage => + 'Αποστολή σε μόνιμη αποθήκευση δωματίου'; + + @override + String get deleteDrawingConfirm => + 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το σχέδιο;'; + + @override + String get drawingDeleted => 'Το σχέδιο διαγράφηκε'; + + @override + String yourDrawingsCount(int count) { + return 'Τα σχέδιά σας ($count)'; + } + + @override + String get shared => 'Κοινοποιημένο'; + + @override + String get line => 'Γραμμή'; + + @override + String get rectangle => 'Ορθογώνιο'; + + @override + String get updateAvailable => 'Διαθέσιμη ενημέρωση'; + + @override + String get currentVersion => 'Τρέχουσα'; + + @override + String get latestVersion => 'Τελευταία'; + + @override + String get downloadUpdate => 'Λήψη'; + + @override + String get updateLater => 'Αργότερα'; + + @override + String get cadastralParcels => 'Κτηματολογικά τεμάχια'; + + @override + String get forestRoads => 'Δασικοί δρόμοι'; + + @override + String get wmsOverlays => 'Επικαλύψεις WMS'; + + @override + String get hikingTrails => 'Πεζοπορικά μονοπάτια'; + + @override + String get mainRoads => 'Κύριοι δρόμοι'; + + @override + String get houseNumbers => 'Αριθμοί κτιρίων'; + + @override + String get fireHazardZones => 'Ζώνες κινδύνου πυρκαγιάς'; + + @override + String get historicalFires => 'Ιστορικές πυρκαγιές'; + + @override + String get firebreaks => 'Αντιπυρικές ζώνες'; + + @override + String get krasFireZones => 'Ζώνες πυρκαγιάς Κρας'; + + @override + String get placeNames => 'Τοπωνύμια'; + + @override + String get municipalityBorders => 'Όρια δήμων'; + + @override + String get topographicMap => 'Τοπογραφικός χάρτης 1:25000'; + + @override + String get recentMessages => 'Πρόσφατα μηνύματα'; + + @override + String get addChannel => 'Προσθήκη καναλιού'; + + @override + String get channelName => 'Όνομα καναλιού'; + + @override + String get channelNameHint => 'π.χ. Ομάδα Διάσωσης Άλφα'; + + @override + String get channelSecret => 'Μυστικό καναλιού'; + + @override + String get channelSecretHint => 'Κοινός κωδικός για αυτό το κανάλι'; + + @override + String get channelSecretHelp => + 'Αυτό το μυστικό πρέπει να είναι κοινό σε όλα τα μέλη της ομάδας που χρειάζονται πρόσβαση σε αυτό το κανάλι'; + + @override + String get channelTypesInfo => + 'Κανάλια hash (#team): Το μυστικό δημιουργείται αυτόματα από το όνομα. Ίδιο όνομα = ίδιο κανάλι σε όλες τις συσκευές.\n\nΙδιωτικά κανάλια: Χρησιμοποιήστε ρητό μυστικό. Μόνο όσοι το γνωρίζουν μπορούν να συμμετάσχουν.'; + + @override + String get hashChannelInfo => + 'Κανάλι hash: Το μυστικό θα δημιουργηθεί αυτόματα από το όνομα του καναλιού. Όποιος χρησιμοποιεί το ίδιο όνομα θα μπει στο ίδιο κανάλι.'; + + @override + String get channelNameRequired => 'Το όνομα καναλιού είναι υποχρεωτικό'; + + @override + String get channelNameTooLong => + 'Το όνομα καναλιού πρέπει να έχει έως 31 χαρακτήρες'; + + @override + String get channelSecretRequired => 'Το μυστικό καναλιού είναι υποχρεωτικό'; + + @override + String get channelSecretTooLong => + 'Το μυστικό καναλιού πρέπει να έχει έως 32 χαρακτήρες'; + + @override + String get invalidAsciiCharacters => 'Επιτρέπονται μόνο χαρακτήρες ASCII'; + + @override + String get channelCreatedSuccessfully => 'Το κανάλι δημιουργήθηκε επιτυχώς'; + + @override + String channelCreationFailed(String error) { + return 'Αποτυχία δημιουργίας καναλιού: $error'; + } + + @override + String get deleteChannel => 'Διαγραφή καναλιού'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Είστε βέβαιοι ότι θέλετε να διαγράψετε το κανάλι \"$channelName\"; Αυτή η ενέργεια δεν αναιρείται.'; + } + + @override + String get channelDeletedSuccessfully => 'Το κανάλι διαγράφηκε επιτυχώς'; + + @override + String channelDeletionFailed(String error) { + return 'Αποτυχία διαγραφής καναλιού: $error'; + } + + @override + String get createChannel => 'Δημιουργία καναλιού'; + + @override + String get wizardBack => 'Πίσω'; + + @override + String get wizardSkip => 'Παράλειψη'; + + @override + String get wizardNext => 'Επόμενο'; + + @override + String get wizardGetStarted => 'Ξεκινήστε'; + + @override + String get wizardWelcomeTitle => 'Καλώς ήρθατε στο MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Προβολή οδηγού καλωσορίσματος'; + + @override + String get allTeamContacts => 'Όλες οι επαφές ομάδας'; + + @override + String directMessagesInfo(int count) { + return 'Άμεσα μηνύματα με ACK. Στάλθηκαν σε $count μέλη ομάδας.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'Ο δείκτης SAR στάλθηκε σε $count επαφές'; + } + + @override + String get noContactsAvailable => 'Δεν υπάρχουν διαθέσιμες επαφές ομάδας'; + + @override + String get reply => 'Απάντηση'; + + @override + String get technicalDetails => 'Τεχνικές λεπτομέρειες'; + + @override + String get messageTechnicalDetails => 'Τεχνικές λεπτομέρειες μηνύματος'; + + @override + String get linkQuality => 'Ποιότητα σύνδεσης'; + + @override + String get delivery => 'Παράδοση'; + + @override + String get status => 'Κατάσταση'; + + @override + String get expectedAckTag => 'Αναμενόμενη ετικέτα ACK'; + + @override + String get roundTrip => 'Χρόνος μετ\' επιστροφής'; + + @override + String get retryAttempt => 'Απόπειρα επανάληψης'; + + @override + String get floodFallback => 'Εφεδρική πλημμύρα'; + + @override + String get identity => 'Ταυτότητα'; + + @override + String get messageId => 'Αναγνωριστικό μηνύματος'; + + @override + String get sender => 'Αποστολέας'; + + @override + String get senderKey => 'Κλειδί αποστολέα'; + + @override + String get recipient => 'Παραλήπτης'; + + @override + String get recipientKey => 'Κλειδί παραλήπτη'; + + @override + String get voice => 'Φωνή'; + + @override + String get voiceId => 'Αναγνωριστικό φωνής'; + + @override + String get envelope => 'Φάκελος'; + + @override + String get sessionProgress => 'Πρόοδος συνεδρίας'; + + @override + String get complete => 'Ολοκληρώθηκε'; + + @override + String get rawDump => 'Ακατέργαστα δεδομένα'; + + @override + String get cannotRetryMissingRecipient => + 'Δεν είναι δυνατή η επανάληψη: λείπουν πληροφορίες παραλήπτη'; + + @override + String get voiceUnavailable => 'Η φωνή δεν είναι διαθέσιμη αυτή τη στιγμή'; + + @override + String get requestingVoice => 'Αίτηση φωνής'; + + @override + String get device => 'συσκευή'; + + @override + String get change => 'Αλλαγή'; + + @override + String get wizardOverviewDescription => + 'Αυτή η εφαρμογή συνδυάζει μηνύματα MeshCore, ενημερώσεις πεδίου SAR, χαρτογράφηση και εργαλεία συσκευής σε ένα σημείο.'; + + @override + String get wizardOverviewFeature1 => + 'Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και μηνύματα καναλιού από την κύρια καρτέλα Μηνύματα.'; + + @override + String get wizardOverviewFeature2 => + 'Μοιραστείτε δείκτες SAR, σχεδιάσεις χάρτη, ηχητικά αποσπάσματα και εικόνες μέσω του mesh.'; + + @override + String get wizardOverviewFeature3 => + 'Συνδεθείτε μέσω BLE ή TCP και στη συνέχεια διαχειριστείτε το συνοδευτικό ραδιόφωνο μέσα από την εφαρμογή.'; + + @override + String get wizardMessagingTitle => 'Μηνύματα και αναφορές πεδίου'; + + @override + String get wizardMessagingDescription => + 'Τα μηνύματα εδώ είναι κάτι περισσότερο από απλό κείμενο. Η εφαρμογή υποστηρίζει ήδη πολλαπλά επιχειρησιακά φορτία και ροές μεταφοράς.'; + + @override + String get wizardMessagingFeature1 => + 'Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και κίνηση καναλιού από έναν μόνο συντάκτη.'; + + @override + String get wizardMessagingFeature2 => + 'Δημιουργήστε ενημερώσεις SAR και επαναχρησιμοποιήσιμα πρότυπα SAR για συνηθισμένες αναφορές πεδίου.'; + + @override + String get wizardMessagingFeature3 => + 'Μεταφέρετε φωνητικές συνεδρίες και εικόνες, με ένδειξη προόδου και εκτιμήσεις χρόνου εκπομπής στο περιβάλλον.'; + + @override + String get wizardConnectDeviceTitle => 'Σύνδεση συσκευής'; + + @override + String get wizardConnectDeviceDescription => + 'Συνδέστε το ραδιόφωνο MeshCore, επιλέξτε όνομα και εφαρμόστε μια προρύθμιση ραδιοφώνου πριν συνεχίσετε.'; + + @override + String get wizardSetupBadge => 'Ρύθμιση'; + + @override + String get wizardOverviewBadge => 'Επισκόπηση'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Συνδέθηκε με το $deviceName'; + } + + @override + String get wizardNoDeviceConnected => 'Δεν έχει συνδεθεί ακόμη συσκευή'; + + @override + String get wizardSkipForNow => 'Παράλειψη προς το παρόν'; + + @override + String get wizardDeviceNameLabel => 'Όνομα συσκευής'; + + @override + String get wizardDeviceNameHelp => + 'Αυτό το όνομα ανακοινώνεται σε άλλους χρήστες του MeshCore.'; + + @override + String get wizardConfigRegionLabel => 'Περιοχή ρύθμισης'; + + @override + String get wizardConfigRegionHelp => + 'Χρησιμοποιεί την πλήρη επίσημη λίστα προρυθμίσεων MeshCore. Η προεπιλογή είναι EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Βεβαιωθείτε ότι η επιλεγμένη προρύθμιση ταιριάζει με τους τοπικούς ραδιοφωνικούς κανονισμούς.'; + + @override + String get wizardPresetNote2 => + 'Η λίστα αντιστοιχεί στην επίσημη ροή προρυθμίσεων του εργαλείου ρύθμισης MeshCore.'; + + @override + String get wizardPresetNote3 => + 'Το EU/UK (Narrow) παραμένει επιλεγμένο από προεπιλογή κατά την αρχική ρύθμιση.'; + + @override + String get wizardSaving => 'Αποθήκευση...'; + + @override + String get wizardSaveAndContinue => 'Αποθήκευση και συνέχεια'; + + @override + String get wizardEnterDeviceName => + 'Εισαγάγετε όνομα συσκευής πριν συνεχίσετε.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return 'Αποθηκεύτηκε το $deviceName με $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Επαφές, δωμάτια και αναμεταδότες'; + + @override + String get wizardNetworkDescription => + 'Η καρτέλα Επαφές οργανώνει το δίκτυο που ανακαλύπτετε και τις διαδρομές που μαθαίνετε με τον χρόνο.'; + + @override + String get wizardNetworkFeature1 => + 'Ελέγξτε μέλη ομάδας, αναμεταδότες, δωμάτια, κανάλια και εκκρεμείς ανακοινώσεις σε μία λίστα.'; + + @override + String get wizardNetworkFeature2 => + 'Χρησιμοποιήστε smart ping, σύνδεση σε δωμάτια, μαθημένες διαδρομές και εργαλεία επαναφοράς διαδρομών όταν η συνδεσιμότητα γίνεται χαοτική.'; + + @override + String get wizardNetworkFeature3 => + 'Δημιουργήστε κανάλια και διαχειριστείτε προορισμούς δικτύου χωρίς να φύγετε από την εφαρμογή.'; + + @override + String get wizardMapOpsTitle => 'Χάρτης, ίχνη και κοινόχρηστη γεωμετρία'; + + @override + String get wizardMapOpsDescription => + 'Ο χάρτης της εφαρμογής συνδέεται άμεσα με τα μηνύματα, την παρακολούθηση και τα επικαλύμματα SAR αντί να είναι ξεχωριστός προβολέας.'; + + @override + String get wizardMapOpsFeature1 => + 'Παρακολουθήστε τη θέση σας, τις τοποθεσίες της ομάδας και τα ίχνη κίνησης στον χάρτη.'; + + @override + String get wizardMapOpsFeature2 => + 'Ανοίξτε σχεδιάσεις από μηνύματα, προεπισκοπήστε τες επιτόπου και αφαιρέστε τες από τον χάρτη όταν χρειάζεται.'; + + @override + String get wizardMapOpsFeature3 => + 'Χρησιμοποιήστε προβολές χάρτη αναμεταδοτών και κοινόχρηστα επικαλύμματα για να κατανοήσετε την εμβέλεια του δικτύου στο πεδίο.'; + + @override + String get wizardToolsTitle => 'Εργαλεία πέρα από τα μηνύματα'; + + @override + String get wizardToolsDescription => + 'Υπάρχουν περισσότερα εδώ από τις τέσσερις κύριες καρτέλες. Η εφαρμογή περιλαμβάνει επίσης ρυθμίσεις, διαγνωστικά και προαιρετικές ροές αισθητήρων.'; + + @override + String get wizardToolsFeature1 => + 'Ανοίξτε τη ρύθμιση συσκευής για να αλλάξετε παραμέτρους ραδιοφώνου, τηλεμετρία, ισχύ TX και στοιχεία συνοδευτικής συσκευής.'; + + @override + String get wizardToolsFeature2 => + 'Ενεργοποιήστε την καρτέλα Αισθητήρες όταν θέλετε πίνακες παρακολούθησης και γρήγορες ενέργειες ανανέωσης.'; + + @override + String get wizardToolsFeature3 => + 'Χρησιμοποιήστε καταγραφές πακέτων, σάρωση φάσματος και διαγνωστικά προγραμματιστή κατά την αντιμετώπιση προβλημάτων του mesh.'; + + @override + String get postConnectDiscoveryTitle => 'Ανακάλυψη αναμεταδοτών τώρα;'; + + @override + String get postConnectDiscoveryDescription => + 'Εκτελέστε ανακάλυψη αναμεταδοτών αμέσως μετά τη σύνδεση, ώστε να μπορείτε να δείτε κοντινούς κόμβους MeshCore και να τους προσθέσετε στο δίκτυό σας πιο γρήγορα.'; + + @override + String get contactInSensors => 'Στους αισθητήρες'; + + @override + String get contactAddToSensors => 'Προσθήκη στους αισθητήρες'; + + @override + String get contactSetPath => 'Ορισμός διαδρομής'; + + @override + String contactAddedToSensors(String contactName) { + return 'Το $contactName προστέθηκε στους Αισθητήρες'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Αποτυχία εκκαθάρισης διαδρομής: $error'; + } + + @override + String get contactRouteCleared => 'Η διαδρομή εκκαθαρίστηκε'; + + @override + String contactRouteSet(String route) { + return 'Ορίστηκε διαδρομή: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Αποτυχία ορισμού διαδρομής: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'Λήξη χρόνου ACK'; + + @override + String get opcode => 'Opcode'; + + @override + String get payload => 'Ωφέλιμο φορτίο'; + + @override + String get hops => 'Αναπηδήσεις'; + + @override + String get hashSize => 'Μέγεθος hash'; + + @override + String get pathBytes => 'Bytes διαδρομής'; + + @override + String get selectedPath => 'Επιλεγμένη διαδρομή'; + + @override + String get estimatedTx => 'Εκτιμώμενη εκπομπή'; + + @override + String get senderToReceipt => 'Αποστολέας σε απόδειξη'; + + @override + String get receivedCopies => 'Ληφθέντα αντίγραφα'; + + @override + String get retryCause => 'Αιτία επανάληψης'; + + @override + String get retryMode => 'Λειτουργία επανάληψης'; + + @override + String get retryResult => 'Αποτέλεσμα επανάληψης'; + + @override + String get lastRetry => 'Τελευταία επανάληψη'; + + @override + String get rxPackets => 'Πακέτα RX'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Ρυθμός'; + + @override + String get window => 'Παράθυρο'; + + @override + String get posttxDelay => 'Καθυστέρηση μετά-tx'; + + @override + String get bandpass => 'Ζωνοπερατό'; + + @override + String get bandpassFilterVoice => 'Ζωνοπερατό φίλτρο φωνής'; + + @override + String get active => 'Ενεργό'; + + @override + String get addContact => 'Προσθήκη επαφής'; + + @override + String get all => 'Όλα'; + + @override + String get clearAllLabel => 'Εκκαθάριση όλων'; + + @override + String get clearFilters => 'Εκκαθάριση φίλτρων'; + + @override + String get clearRoute => 'Εκκαθάριση διαδρομής'; + + @override + String get clearMessages => 'Εκκαθάριση μηνυμάτων'; + + @override + String get clearScale => 'Εκκαθάριση κλίμακας'; + + @override + String get clearDiscoveries => 'Εκκαθάριση ανακαλύψεων'; + + @override + String get clearOnlineTraceDatabase => 'Εκκαθάριση βάσης ιχνών'; + + @override + String get clearAllChannels => 'Εκκαθάριση όλων των καναλιών'; + + @override + String get clearAllContacts => 'Εκκαθάριση όλων των επαφών'; + + @override + String get clearChannels => 'Εκκαθάριση καναλιών'; + + @override + String get clearContacts => 'Εκκαθάριση επαφών'; + + @override + String get clearPathOnMaxRetry => 'Εκκαθάριση μονοπατιού στο μέγιστο'; + + @override + String get create => 'Δημιουργία'; + + @override + String get custom => 'Προσαρμοσμένο'; + + @override + String get defaultValue => 'Προεπιλογή'; + + @override + String get duplicate => 'Αντιγραφή'; + + @override + String get editName => 'Επεξεργασία ονόματος'; + + @override + String get open => 'Άνοιγμα'; + + @override + String get paste => 'Επικόλληση'; + + @override + String get preview => 'Προεπισκόπηση'; + + @override + String get remove => 'Αφαίρεση'; + + @override + String get rename => 'Μετονομασία'; + + @override + String get resolveAll => 'Επίλυση όλων'; + + @override + String get send => 'Αποστολή'; + + @override + String get sendAnyway => 'Αποστολή ούτως ή άλλως'; + + @override + String get share => 'Κοινοποίηση'; + + @override + String get trace => 'Ίχνος'; + + @override + String get discovery => 'Ανακάλυψη'; + + @override + String get discoverRepeaters => 'Ανακάλυψη αναμεταδοτών'; + + @override + String get discoverSensors => 'Ανακάλυψη αισθητήρων'; + + @override + String get repeaterDiscoverySent => 'Αποστολή ανακάλυψης αναμεταδοτών'; + + @override + String get sensorDiscoverySent => 'Αποστολή ανακάλυψης αισθητήρων'; + + @override + String get clearedPendingDiscoveries => 'Εκκαθάριση εκκρεμών ανακαλύψεων.'; + + @override + String get autoDiscovery => 'Αυτόματη ανακάλυψη'; + + @override + String get enableAutomaticAdding => 'Ενεργοποίηση αυτόματης προσθήκης'; + + @override + String get autoaddRepeaters => 'Αυτόματη προσθήκη αναμεταδοτών'; + + @override + String get autoaddRoomServers => 'Αυτόματη προσθήκη διακομιστών δωματίου'; + + @override + String get autoaddSensors => 'Αυτόματη προσθήκη αισθητήρων'; + + @override + String get autoaddUsers => 'Αυτόματη προσθήκη χρηστών'; + + @override + String get overwriteOldestWhenFull => + 'Αντικατάσταση παλαιότερων όταν γεμίσει'; + + @override + String get storage => 'Αποθήκευση'; + + @override + String get dangerZone => 'Ζώνη κινδύνου'; + + @override + String get profiles => 'Προφίλ'; + + @override + String get favourites => 'Αγαπημένα'; + + @override + String get sensors => 'Αισθητήρες'; + + @override + String get others => 'Άλλοι'; + + @override + String get gpsModule => 'Μονάδα GPS'; + + @override + String get liveTraffic => 'Ζωντανή κίνηση'; + + @override + String get repeatersMap => 'Χάρτης αναμεταδοτών'; + + @override + String get blePacketLogs => 'Αρχεία πακέτων BLE'; + + @override + String get onlineTraceDatabase => 'Βάση δεδομένων ιχνών'; + + @override + String get routePathByteSize => 'Μέγεθος διαδρομής σε bytes'; + + @override + String get messageNotifications => 'Ειδοποιήσεις μηνυμάτων'; + + @override + String get sarAlerts => 'Ειδοποιήσεις SAR'; + + @override + String get discoveryNotifications => 'Ειδοποιήσεις ανακάλυψης'; + + @override + String get updateNotifications => 'Ειδοποιήσεις ενημερώσεων'; + + @override + String get muteWhileAppIsOpen => 'Σίγαση με ανοιχτή εφαρμογή'; + + @override + String get disableContacts => 'Απενεργοποίηση επαφών'; + + @override + String get enableSensorsTab => 'Ενεργοποίηση καρτέλας Αισθητήρων'; + + @override + String get enableProfiles => 'Ενεργοποίηση προφίλ'; + + @override + String get nearestRepeaterFallback => 'Πλησιέστερος αναμεταδότης ως εφεδρεία'; + + @override + String get deleteAllStoredMessageHistory => 'Διαγραφή όλου του ιστορικού'; + + @override + String get messageFontSize => 'Μέγεθος γραμματοσειράς μηνυμάτων'; + + @override + String get rotateMapWithHeading => 'Περιστροφή χάρτη με κατεύθυνση'; + + @override + String get showMapDebugInfo => 'Εμφάνιση πληροφοριών αποσφαλμάτωσης'; + + @override + String get openMapInFullscreen => 'Άνοιγμα χάρτη σε πλήρη οθόνη'; + + @override + String get showSarMarkersLabel => 'Εμφάνιση δεικτών SAR'; + + @override + String get hideRepeatersOnMap => 'Απόκρυψη αναμεταδοτών στον χάρτη'; + + @override + String get setMapScale => 'Ρύθμιση κλίμακας χάρτη'; + + @override + String get customMapScaleSaved => 'Προσαρμοσμένη κλίμακα χάρτη αποθηκεύτηκε'; + + @override + String get voiceBitrate => 'Ρυθμός bit φωνής'; + + @override + String get voiceCompressor => 'Συμπιεστής φωνής'; + + @override + String get voiceLimiter => 'Περιοριστής φωνής'; + + @override + String get micAutoGain => 'Αυτόματη ενίσχυση μικροφώνου'; + + @override + String get echoCancellation => 'Ακύρωση ηχούς'; + + @override + String get noiseSuppression => 'Καταστολή θορύβου'; + + @override + String get trimSilenceInVoiceMessages => + 'Περικοπή σιωπής σε φωνητικά μηνύματα'; + + @override + String get compressor => 'Συμπιεστής'; + + @override + String get limiter => 'Περιοριστής'; + + @override + String get autoGain => 'Αυτόματη ενίσχυση'; + + @override + String get echoCancel => 'Ηχώ'; + + @override + String get noiseSuppress => 'Θόρυβος'; + + @override + String get silenceTrim => 'Σιωπή'; + + @override + String get maxImageSize => 'Μέγιστο μέγεθος εικόνας'; + + @override + String get imageCompression => 'Συμπίεση εικόνας'; + + @override + String get grayscale => 'Κλίμακα του γκρι'; + + @override + String get ultraMode => 'Λειτουργία ultra'; + + @override + String get fastPrivateGpsUpdates => 'Γρήγορες ιδιωτικές ενημερώσεις GPS'; + + @override + String get movementThreshold => 'Κατώφλι κίνησης'; + + @override + String get fastGpsMovementThreshold => 'Κατώφλι κίνησης γρήγορου GPS'; + + @override + String get fastGpsActiveuseInterval => 'Διάστημα ενεργής χρήσης γρήγορου GPS'; + + @override + String get activeuseUpdateInterval => 'Διάστημα ενημέρωσης ενεργής χρήσης'; + + @override + String get repeatNearbyTraffic => 'Επανάληψη κοντινής κίνησης'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Αναμετάδοση μέσω αναμεταδοτών στο δίκτυο'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Μόνο κοντά, χωρίς πλημμυρισμό'; + + @override + String get multihop => 'Πολλαπλών αναπηδήσεων'; + + @override + String get createProfile => 'Δημιουργία προφίλ'; + + @override + String get renameProfile => 'Μετονομασία προφίλ'; + + @override + String get newProfile => 'Νέο προφίλ'; + + @override + String get manageProfiles => 'Διαχείριση προφίλ'; + + @override + String get enableProfilesToStartManagingThem => + 'Ενεργοποιήστε τα προφίλ για να τα διαχειριστείτε.'; + + @override + String get openMessage => 'Άνοιγμα μηνύματος'; + + @override + String get jumpToTheRelatedSarMessage => 'Μετάβαση στο σχετικό μήνυμα SAR'; + + @override + String get removeSarMarker => 'Αφαίρεση δείκτη SAR'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Επιλέξτε προορισμό για αποστολή δείκτη SAR'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'Δείκτης SAR εκπεμπόμενος στο δημόσιο κανάλι'; + + @override + String get sarMarkerSentToRoom => 'Δείκτης SAR εστάλη στο δωμάτιο'; + + @override + String get loadFromGallery => 'Φόρτωση από τη γκαλερί'; + + @override + String get replaceImage => 'Αντικατάσταση εικόνας'; + + @override + String get selectFromGallery => 'Επιλογή από τη γκαλερί'; + + @override + String get team => 'Ομάδα'; + + @override + String get found => 'Βρέθηκε'; + + @override + String get staging => 'Χώρος συγκέντρωσης'; + + @override + String get object => 'Αντικείμενο'; + + @override + String get quiet => 'Ήσυχο'; + + @override + String get busy => 'Απασχολημένο'; + + @override + String get searchMessages => 'Αναζήτηση μηνυμάτων'; + + @override + String get sendImageFromGallery => 'Αποστολή εικόνας από τη γκαλερί'; + + @override + String get takePhoto => 'Λήψη φωτογραφίας'; + + @override + String get allMessages => 'Όλα τα μηνύματα'; + + @override + String get sendToPublicChannel => 'Αποστολή στο δημόσιο κανάλι;'; + + @override + String get selectMarkerTypeAndDestination => + 'Επιλέξτε τύπο δείκτη και προορισμό'; + + @override + String get noDestinationsAvailableLabel => + 'Δεν υπάρχουν διαθέσιμοι προορισμοί'; + + @override + String get image => 'Εικόνα'; + + @override + String get format => 'Μορφή'; + + @override + String get dimensions => 'Διαστάσεις'; + + @override + String get segments => 'Τμήματα'; + + @override + String get transfers => 'Μεταφορές'; + + @override + String get downloadedBy => 'Λήφθηκε από'; + + @override + String get saveDiscoverySettings => 'Αποθήκευση ρυθμίσεων ανακάλυψης'; + + @override + String get savePublicInfo => 'Αποθήκευση δημόσιων πληροφοριών'; + + @override + String get saveRadioSettings => 'Αποθήκευση ρυθμίσεων ραδιοφώνου'; + + @override + String get savePath => 'Αποθήκευση διαδρομής'; + + @override + String get wipeDeviceData => 'Διαγραφή δεδομένων συσκευής'; + + @override + String get wipeDevice => 'Διαγραφή συσκευής'; + + @override + String get destructiveDeviceActions => 'Καταστροφικές ενέργειες συσκευής.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Επιλέξτε μια προεπιλογή ή ρυθμίστε τις ρυθμίσεις ραδιοφώνου.'; + + @override + String get used => 'Χρησιμοποιήθηκε'; + + @override + String get total => 'Σύνολο'; + + @override + String get renameValue => 'Μετονομασία τιμής'; + + @override + String get customizeFields => 'Προσαρμογή πεδίων'; + + @override + String get livePreview => 'Ζωντανή προεπισκόπηση'; + + @override + String get refreshSchedule => 'Χρονοδιάγραμμα ανανέωσης'; + + @override + String get noResponse => 'Χωρίς απάντηση'; + + @override + String get refreshing => 'Ανανέωση'; + + @override + String get unavailable => 'Μη διαθέσιμο'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Επιλέξτε ένα relay ή κόμβο για παρακολούθηση.'; + + @override + String get publicKeyLabel => 'Δημόσιο κλειδί'; + + @override + String get alreadyInContacts => 'Ήδη στις επαφές'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Συνδεθείτε σε μια συσκευή πριν προσθέσετε επαφές'; + + @override + String get fromContacts => 'Από τις επαφές'; + + @override + String get onlineOnly => 'Μόνο συνδεδεμένοι'; + + @override + String get inBoth => 'Σε αμφότερα'; + + @override + String get source => 'Πηγή'; + + @override + String get allMessagesCleared => 'Όλα τα μηνύματα διαγράφηκαν'; + + @override + String get onlineTraceDatabaseCleared => 'Βάση δεδομένων ιχνών εκκαθαρίστηκε'; + + @override + String get packetLogsCleared => 'Τα αρχεία πακέτων εκκαθαρίστηκαν'; + + @override + String get hexDataCopiedToClipboard => 'Τα δεδομένα hex αντιγράφηκαν'; + + @override + String get developerModeEnabled => 'Λειτουργία προγραμματιστή ενεργοποιήθηκε'; + + @override + String get developerModeDisabled => + 'Λειτουργία προγραμματιστή απενεργοποιήθηκε'; + + @override + String get clipboardIsEmpty => 'Το πρόχειρο είναι κενό'; + + @override + String get contactImported => 'Επαφή εισήχθη'; + + @override + String get contactLinkCopiedToClipboard => 'Ο σύνδεσμος επαφής αντιγράφηκε'; + + @override + String get failedToExportContact => 'Αποτυχία εξαγωγής επαφής'; + + @override + String get noLogsToExport => 'Δεν υπάρχουν αρχεία για εξαγωγή'; + + @override + String get exportAsCsv => 'Εξαγωγή ως CSV'; + + @override + String get exportAsText => 'Εξαγωγή ως κείμενο'; + + @override + String get receivedRfc3339 => 'Ελήφθη (RFC3339)'; + + @override + String get buildTime => 'Ώρα κατασκευής'; + + @override + String get downloadUrlNotAvailable => 'Η URL λήψης δεν είναι διαθέσιμη'; + + @override + String get cannotOpenDownloadUrl => 'Αδυναμία ανοίγματος URL λήψης'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'Ο έλεγχος ενημερώσεων είναι διαθέσιμος μόνο σε Android'; + + @override + String get youAreRunningTheLatestVersion => + 'Χρησιμοποιείτε την πιο πρόσφατη έκδοση'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Διαθέσιμη ενημέρωση αλλά η URL λήψης δεν βρέθηκε'; + + @override + String get startTictactoe => 'Εκκίνηση Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe μη διαθέσιμο'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: άγνωστος αντίπαλος'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: αναμονή για εκκίνηση'; + + @override + String get acceptsShareLinks => 'Αποδέχεται κοινόχρηστους συνδέσμους'; + + @override + String get supportsRawHex => 'Υποστηρίζει ακατέργαστο hex'; + + @override + String get clipboardfriendly => 'Φιλικό προς πρόχειρο'; + + @override + String get captured => 'Καταγράφηκε'; + + @override + String get size => 'Μέγεθος'; + + @override + String get noCustomChannelsToClear => 'Δεν υπάρχουν προσαρμοσμένα κανάλια.'; + + @override + String get noDeviceContactsToClear => 'Δεν υπάρχουν επαφές συσκευής.'; + + @override + String get aToZ => 'A-Z'; + + @override + String get add => 'Προσθήκη'; + + @override + String get addAdditionalDetails => 'Προσθήκη επιπλέον λεπτομερειών'; + + @override + String get addPeer => 'Προσθήκη κόμβου'; + + @override + String get addPeerManually => 'Χειροκίνητη προσθήκη κόμβου'; + + @override + String addedToContacts(String name) { + return 'Ο/Η $name προστέθηκε στις επαφές'; + } + + @override + String addedToSensors(String name) { + return 'Ο/Η $name προστέθηκε στους αισθητήρες'; + } + + @override + String get allowAll => 'Επιτρέπονται όλα'; + + @override + String get anonymousRxStats => 'Ανώνυμα στατιστικά λήψης'; + + @override + String get appearance => 'Εμφάνιση'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Βασική τηλεμετρία'; + + @override + String get blePin => 'BLE PIN'; + + @override + String get buzzerAlerts => 'Ειδοποιήσεις βομβητή'; + + @override + String get cancelImageReceive => 'Ακύρωση λήψης εικόνας'; + + @override + String get choose => 'Επιλογή'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Επιλέξτε ποιες καρτέλες και ενότητες επαφών θα εμφανίζονται'; + + @override + String get clearCache => 'Εκκαθάριση προσωρινής μνήμης'; + + @override + String get clearHistory => 'Εκκαθάριση ιστορικού'; + + @override + String get clearLiveView => 'Εκκαθάριση ζωντανής προβολής'; + + @override + String get clearLogs => 'Εκκαθάριση αρχείων καταγραφής'; + + @override + String get clearOfflineCache => + 'Εκκαθάριση προσωρινής μνήμης εκτός σύνδεσης;'; + + @override + String get clearOverlay => 'Εκκαθάριση επικάλυψης'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Εκκαθάριση διαδρομής μόνο μετά την αποτυχία όλων των επαναπροσπαθειών'; + + @override + String get codecBitrateAndAudioProcessing => + 'Κωδικοποιητής, ρυθμός bit και επεξεργασία ήχου'; + + @override + String get connectByIpAddress => 'Σύνδεση μέσω διεύθυνσης IP'; + + @override + String get controlWhichAlertsYouReceive => + 'Ελέγξτε ποιες ειδοποιήσεις λαμβάνετε'; + + @override + String get copyFullHex => 'Αντιγραφή πλήρους Hex'; + + @override + String get copyRawResponse => 'Αντιγραφή ακατέργαστης απόκρισης'; + + @override + String get couldNotLoadMetHistory => 'Αδυναμία φόρτωσης ιστορικού MET'; + + @override + String customizeSensor(String name) { + return 'Προσαρμογή $name'; + } + + @override + String get data => 'Δεδομένα'; + + @override + String get deleteGroup => 'Διαγραφή ομάδας'; + + @override + String deleteStyleConfirm(String name) { + return 'Διαγραφή $name;'; + } + + @override + String deleteThisDrawing(String type) { + return 'Διαγραφή αυτού του $type;'; + } + + @override + String get deny => 'Απόρριψη'; + + @override + String get deviceInfo => 'Πληροφορίες συσκευής'; + + @override + String get deviceSettings => 'Ρυθμίσεις συσκευής'; + + @override + String get deviceTimeSynced => + 'Ο χρόνος της συσκευής συγχρονίστηκε με αυτό το τηλέφωνο.'; + + @override + String get disableFastGpsPublishing => + 'Απενεργοποίηση γρήγορης δημοσίευσης GPS'; + + @override + String get displayMarkersAndTraceDatabase => + 'Εμφάνιση, δείκτες και βάση δεδομένων ιχνών'; + + @override + String get download => 'Λήψη'; + + @override + String get environmentalTelemetry => 'Περιβαλλοντική τηλεμετρία'; + + @override + String errorCheckingForUpdates(String error) { + return 'Σφάλμα ελέγχου ενημερώσεων: $error'; + } + + @override + String errorGeneric(String error) { + return 'Σφάλμα: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Σφάλμα ανοίγματος λήψης: $error'; + } + + @override + String get exportLogs => 'Εξαγωγή αρχείων καταγραφής'; + + @override + String get extraAggressiveAvifCompression => + 'Εξαιρετικά επιθετική συμπίεση AVIF'; + + @override + String failedToAddContact(String error) { + return 'Αποτυχία προσθήκης επαφής: $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Αποτυχία προσθήκης $name: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Αποτυχία εκκαθάρισης καναλιών: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Αποτυχία εκκαθάρισης επαφών: $error'; + } + + @override + String get failedToConnectViaSerial => + 'Αποτυχία σύνδεσης μέσω σειριακής θύρας'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Αποτυχία φόρτωσης εικόνας προεπισκόπησης: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Αποτυχία φόρτωσης ίχνους: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Αποτυχία αποστολής δείκτη SAR: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Αποτυχία ρύθμισης λειτουργίας βομβητή: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Αποτυχία ρύθμισης λειτουργίας GPS: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Αποτυχία συγχρονισμού χρόνου συσκευής: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Αποτυχία διαγραφής δεδομένων συσκευής: $error'; + } + + @override + String get fastGpsTargetChannel => 'Κανάλι-στόχος γρήγορου GPS'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Γρήγορες ενημερώσεις, όρια και δικαιώματα'; + + @override + String get fetchingCatalog => 'Ανάκτηση καταλόγου...'; + + @override + String get fifteenMinutes => '15 λεπτά'; + + @override + String get fifteenSeconds => '15 δευτερόλεπτα'; + + @override + String get filterByDirection => 'Φιλτράρισμα κατά κατεύθυνση'; + + @override + String get finish => 'Τέλος'; + + @override + String get fiveMinutes => '5 λεπτά'; + + @override + String get fiveSeconds => '5 δευτερόλεπτα'; + + @override + String get fix => 'Στίξη'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'Πολιτική διαφήμισης GPS'; + + @override + String get gpsAndLocation => 'GPS & Τοποθεσία'; + + @override + String get gpsDiagnostics => 'Διαγνωστικά GPS'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Βοήθεια'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Κρυφό'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Πώς το ραδιόφωνο προσθέτει αυτόματα ανακαλυφθέντες κόμβους.'; + + @override + String get images => 'Εικόνες'; + + @override + String get importProfile => 'Εισαγωγή προφίλ'; + + @override + String get lastFix => 'Τελευταία στίξη'; + + @override + String get latest => 'Πιο πρόσφατο'; + + @override + String get latitude => 'Γεωγραφικό πλάτος'; + + @override + String get loadASavedRegion => 'Φόρτωση αποθηκευμένης περιοχής'; + + @override + String get loading => 'Φόρτωση'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Κοινοποίηση τοποθεσίας, υλικό και διάστημα ενημέρωσης.'; + + @override + String get locationTelemetry => 'Τηλεμετρία τοποθεσίας'; + + @override + String get lockDestination => 'Κλείδωμα προορισμού'; + + @override + String get longitude => 'Γεωγραφικό μήκος'; + + @override + String get mapStyle => 'Στυλ χάρτη'; + + @override + String get max => 'Μέγ.'; + + @override + String get maxZoom => 'Μέγ. ζουμ'; + + @override + String get messaging => 'Μηνύματα'; + + @override + String get metHistory => 'Ιστορικό MET'; + + @override + String get min => 'Ελάχ.'; + + @override + String get minZoom => 'Ελάχ. ζουμ'; + + @override + String get moveDown => 'Μετακίνηση κάτω'; + + @override + String get moveUp => 'Μετακίνηση πάνω'; + + @override + String get multiAckMode => 'Λειτουργία πολλαπλού ACK'; + + @override + String get multiDeviceWorkspaceManagement => + 'Διαχείριση χώρου εργασίας πολλαπλών συσκευών'; + + @override + String get myLocation => 'Η τοποθεσία μου'; + + @override + String get nameAndTelemetryShared => + 'Το όνομα και η τηλεμετρία κοινοποιούνται σε άλλες συσκευές.'; + + @override + String newVersionAvailable(String version) { + return 'Νέα έκδοση: $version'; + } + + @override + String get newer => 'Νεότερο'; + + @override + String get noNeighboursFound => 'Δεν βρέθηκαν γείτονες'; + + @override + String get noServersFound => 'Δεν βρέθηκαν διακομιστές'; + + @override + String get notSet => 'Δεν έχει οριστεί'; + + @override + String get notifications => 'Ειδοποιήσεις'; + + @override + String get offZeroSeconds => 'Ανενεργό (0 δ)'; + + @override + String get offlineMaps => 'Χάρτες εκτός σύνδεσης'; + + @override + String get older => 'Παλαιότερο'; + + @override + String get oneByteMode0 => '1 byte (λειτουργία 0)'; + + @override + String get oneHour => '1 ώρα'; + + @override + String get oneMinute => '1 λεπτό'; + + @override + String get openPacketLogs => 'Άνοιγμα αρχείων πακέτων'; + + @override + String get packetTypeHelp => 'Βοήθεια τύπων πακέτων'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Επικολλήστε σύνδεσμο κοινοποίησης ή hex διαφήμιση'; + + @override + String get pathHash => 'Hash διαδρομής'; + + @override + String get pathSize => 'Μέγεθος διαδρομής'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Πολύγωνο'; + + @override + String get rawResponseCopied => 'Η ακατέργαστη απόκριση αντιγράφηκε'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Επαναποστολή μέσω πλησιέστερου αναμεταδότη σε αποτυχία'; + + @override + String get resolveContact => 'Επίλυση επαφής'; + + @override + String get routingRetriesAndDestinationLock => + 'Δρομολόγηση, επαναπροσπάθειες και κλείδωμα προορισμού'; + + @override + String get samples => 'Δείγματα'; + + @override + String get satellites => 'Δορυφόροι'; + + @override + String get saving => 'Αποθήκευση...'; + + @override + String get search => 'Αναζήτηση'; + + @override + String get searchByNameOrToken => 'Αναζήτηση με όνομα ή token'; + + @override + String get searchDiscoveredNodes => 'Αναζήτηση ανακαλυφθέντων κόμβων'; + + @override + String get searchInCurrentFilter => 'Αναζήτηση στο τρέχον φίλτρο'; + + @override + String get searchLogs => 'Αναζήτηση αρχείων καταγραφής...'; + + @override + String get searchSensors => 'Αναζήτηση αισθητήρων'; + + @override + String get searchThisSection => 'Αναζήτηση σε αυτήν την ενότητα'; + + @override + String get selectedRelays => 'Επιλεγμένοι αναμεταδότες'; + + @override + String get sensor => 'Αισθητήρας'; + + @override + String serialError(String error) { + return 'Σφάλμα σειριακής: $error'; + } + + @override + String setPathForContact(String name) { + return 'Ορισμός διαδρομής για $name'; + } + + @override + String get shareLiveGps => 'Κοινοποίηση ζωντανού GPS'; + + @override + String get shareMyTiles => 'Κοινοποίηση πλακιδίων μου'; + + @override + String get sharingLocationFromPhone => + 'Κοινοποίηση τοποθεσίας σε αυτό το κανάλι από το τηλέφωνο.'; + + @override + String get sharingLocationFromRadio => + 'Κοινοποίηση τοποθεσίας σε αυτό το κανάλι από το ραδιόφωνο.'; + + @override + String signalDbm(String rssi) { + return 'Σήμα $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => 'Μέγεθος, συμπίεση και προεπισκόπηση'; + + @override + String get simpleMode => 'Απλή λειτουργία'; + + @override + String get simpleModeDescription => + 'Μόνο συνομιλία και χάρτης με αγαπημένες επαφές'; + + @override + String get simpleModeRequiresLockedDestination => + 'Ενεργοποιήστε πρώτα το «Κλείδωμα προορισμού» με ένα κανάλι για να χρησιμοποιήσετε την Απλή λειτουργία'; + + @override + String get noFavouriteContacts => 'Καμία αγαπημένη επαφή'; + + @override + String get skip => 'Παράλειψη'; + + @override + String get smallerFileSize => 'Μικρότερο μέγεθος αρχείου'; + + @override + String get sort => 'Ταξινόμηση'; + + @override + String get stoppedSharingLocation => + 'Σταμάτησε η κοινοποίηση τοποθεσίας σε αυτό το κανάλι.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Αποσιώπηση ειδοποιήσεων όταν η εφαρμογή είναι στο προσκήνιο'; + + @override + String get tabsAndNavigation => 'Καρτέλες & Πλοήγηση'; + + @override + String get templatesAndTutorials => 'Πρότυπα και οδηγίες'; + + @override + String get tenMinutes => '10 λεπτά'; + + @override + String get tenSeconds => '10 δευτερόλεπτα'; + + @override + String get testSendUpdate => 'Δοκιμαστική αποστολή ενημέρωσης'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Θέμα, γλώσσα και προτιμήσεις εμφάνισης'; + + @override + String get thirtyMinutes => '30 λεπτά'; + + @override + String get thirtySeconds => '30 δευτερόλεπτα'; + + @override + String get threeBytesMode2 => '3 bytes (λειτουργία 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Στατιστικά κίνησης, ιστορικό μηνυμάτων και εργαλεία προγραμματιστή'; + + @override + String get twoBytesMode1 => '2 bytes (λειτουργία 1)'; + + @override + String get twoMinutes => '2 λεπτά'; + + @override + String get undo => 'Αναίρεση'; + + @override + String get updated => 'Ενημερώθηκε'; + + @override + String get useContactFlags => 'Χρήση σημαιών επαφής'; + + @override + String get useSavedCoordinates => 'Χρήση αποθηκευμένων συντεταγμένων'; + + @override + String get view => 'Προβολή'; + + @override + String get viewNeighbours => 'Προβολή γειτόνων'; + + @override + String get viewPublicStats => 'Προβολή δημόσιων στατιστικών'; + + @override + String get setRegionScope => 'Ορισμός εύρους περιοχής'; + + @override + String get regionScope => 'Εύρος περιοχής'; + + @override + String get regionScopeNone => 'Κανένα (καθολικό)'; + + @override + String get clearRegionScope => 'Εκκαθάριση εύρους'; + + @override + String get regionScopeWarning => + 'Μόνο οι αναμεταδότες που επιτρέπουν αυτή την περιοχή θα προωθήσουν.'; + + @override + String get discoverRegions => 'Ανακάλυψη από αναμεταδότες'; + + @override + String get discoveringRegions => 'Ανακάλυψη περιοχών...'; + + @override + String get enterRegionName => 'Όνομα περιοχής (π.χ. auckland)'; + + @override + String get noRegionsFound => + 'Δεν βρέθηκαν περιοχές σε αυτόν τον αναμεταδότη.'; + + @override + String regionScopeSet(String name) { + return 'Εύρος περιοχής ορίστηκε σε $name'; + } + + @override + String get regionScopeCleared => 'Εύρος περιοχής εκκαθαρίστηκε'; + + @override + String get selectDestinationFirst => 'Επιλέξτε πρώτα κανάλι, επαφή ή δωμάτιο'; + + @override + String get recipientNoLongerAvailable => + 'Ο παραλήπτης δεν είναι πλέον διαθέσιμος'; + + @override + String failedToSendMessage(String error) { + return 'Αποτυχία αποστολής: $error'; + } + + @override + String get tictactoeDirectOnly => + 'Η τρίλιζα λειτουργεί μόνο σε άμεσα μηνύματα. Επιλέξτε πρώτα μια επαφή.'; + + @override + String get deviceKeyUnavailable => + 'Το κλειδί της συσκευής δεν είναι διαθέσιμο'; + + @override + String get imageCompressionFailed => 'Αποτυχία συμπίεσης εικόνας'; + + @override + String get imageFragmentationFailed => 'Αποτυχία κατακερματισμού εικόνας'; + + @override + String get failedToAnnounceImage => 'Αποτυχία ανακοίνωσης εικόνας'; + + @override + String get noRecipientSelected => 'Δεν επιλέχθηκε παραλήπτης'; + + @override + String get imageSendFailed => 'Αποτυχία αποστολής εικόνας'; + + @override + String get microphonePermissionRequiredForVoice => + 'Απαιτείται άδεια μικροφώνου για φωνητικά μηνύματα'; + + @override + String get recordingDiscarded => 'Η ηχογράφηση απορρίφθηκε'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Πρόκειται να στείλετε $mediaType στο δημόσιο κανάλι. Δεν συνιστάται, καθώς μπορεί να το λάβουν όλοι στο δίκτυο mesh. Επιλέξτε ιδιωτικό ή επισημασμένο κανάλι, εκτός αν αυτό θέλετε.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Επιλέξτε ιδιωτικό κανάλι για να μοιραστείτε την τοποθεσία σας.'; + + @override + String get stopSharingMyLocation => 'Διακοπή κοινοποίησης της τοποθεσίας μου'; + + @override + String get shareMyLocation => 'Κοινοποίηση της τοποθεσίας μου'; + + @override + String get stopRecording => 'Διακοπή ηχογράφησης'; + + @override + String get recordVoice => 'Ηχογράφηση φωνητικού μηνύματος'; + + @override + String get moreActions => 'Περισσότερες ενέργειες'; + + @override + String get noMessagesInFilter => 'Δεν υπάρχουν μηνύματα σε αυτό το φίλτρο'; + + @override + String get noMatchesInFilter => 'Δεν υπάρχουν αποτελέσματα σε αυτό το φίλτρο'; + + @override + String syncFailed(String error) { + return 'Αποτυχία συγχρονισμού: $error'; + } + + @override + String get invalidLink => 'Μη έγκυρος σύνδεσμος'; + + @override + String get cannotOpenLink => 'Δεν είναι δυνατό το άνοιγμα του συνδέσμου'; + + @override + String get failedToOpenLink => 'Αποτυχία ανοίγματος του συνδέσμου'; + + @override + String get failedToResendMessage => 'Αποτυχία επαναποστολής του μηνύματος'; + + @override + String retryFailed(String error) { + return 'Η επανάληψη απέτυχε: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Διαγραφή σχεδίου και μηνύματος'; + + @override + String get lastEchoRelay => 'Τελευταία ηχώ: αναμεταδότης'; + + @override + String get lastEchoPath => 'Τελευταία ηχώ: διαδρομή'; + + @override + String get lastEchoBytesReport => 'Τελευταία ηχώ: αναφορά byte'; + + @override + String get noGpsCoordinatesAvailable => + 'Δεν υπάρχουν διαθέσιμες συντεταγμένες GPS'; + + @override + String get notASarMarker => 'Δεν είναι δείκτης SAR'; + + @override + String failedToSaveTemplate(String error) { + return 'Αποτυχία αποθήκευσης προτύπου: $error'; + } + + @override + String get drawingNotFound => 'Το σχέδιο δεν βρέθηκε'; + + @override + String get unknownDrawingType => 'Άγνωστος τύπος σχεδίου'; + + @override + String get customMapMarker => 'Προσαρμοσμένος δείκτης χάρτη'; + + @override + String customMapPointLabel(String point) { + return 'Σημείο: $point'; + } + + @override + String mapIdLabel(String id) { + return 'ID χάρτη: $id'; + } + + @override + String get cannotFetchVoice => 'Δεν είναι δυνατή η λήψη της φωνής'; + + @override + String get senderContactUnknown => + 'Η επαφή του αποστολέα είναι άγνωστη. Συγχρονίστε πρώτα τις επαφές.'; + + @override + String get senderRouteUnknown => + 'Η διαδρομή του αποστολέα είναι άγνωστη. Συγχρονίστε πρώτα επαφές/διαδρομή.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Το μήνυμα είναι πολύ μακριά ($hops αναπηδήσεις, μέγ. $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'Η διαδρομή του αποστολέα δεν απάντησε στον έλεγχο διαδρομής. Συγχρονίστε επαφές/διαδρομή και δοκιμάστε ξανά.'; + + @override + String get senderRouteNoRawResponse => + 'Η διαδρομή του αποστολέα δεν απάντησε στη raw διαδρομή μεταφοράς.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Η λήψη φωνής μέσω $hops αναπηδήσεων μπορεί να διαρκέσει.'; + } + + @override + String get voiceReceiveCanceled => 'Η λήψη φωνής ακυρώθηκε'; + + @override + String get fetchingMissingVoiceFragments => + 'Λήψη φωνητικών τμημάτων που λείπουν'; + + @override + String get receivingVoice => 'Λήψη φωνής'; + + @override + String get cannotFetchImage => 'Δεν είναι δυνατή η λήψη της εικόνας'; + + @override + String imageFetchOverHops(String hops) { + return 'Η λήψη εικόνας μέσω $hops αναπηδήσεων μπορεί να διαρκέσει.'; + } + + @override + String get imageAlreadyBeingReceived => 'Η εικόνα λαμβάνεται ήδη'; + + @override + String get loadImage => 'Φόρτωση εικόνας'; + + @override + String get imageFetchFailedToSendRequest => + 'Αποτυχία αποστολής αιτήματος εικόνας'; + + @override + String get imageUnavailable => 'Η εικόνα δεν είναι διαθέσιμη αυτήν τη στιγμή'; + + @override + String get imageFetchTimedOut => 'Η λήψη της εικόνας έληξε'; + + @override + String get imageReceiveCanceled => 'Η λήψη της εικόνας ακυρώθηκε'; + + @override + String get closeImagePreview => 'Κλείσιμο προεπισκόπησης εικόνας'; + + @override + String get recordingReleaseToSend => 'Ηχογράφηση... αφήστε για αποστολή'; + + @override + String get sendingVoice => 'Αποστολή φωνής...'; + + @override + String get sendLongPressToRecordVoice => + 'Αποστολή (παρατεταμένο πάτημα για ηχογράφηση)'; + + @override + String get discardRecording => 'Απόρριψη ηχογράφησης'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count αναπηδήσεις', + one: '$count αναπήδηση', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Κανάλια εκπομπής για κοντινή κίνηση mesh'; + + @override + String get roomsSectionDescription => + 'Κοινόχρηστοι χώροι για συνεχή συντονισμό ομάδας'; + + @override + String get contactsSectionDescription => + 'Άτομα και συσκευές που μπορείτε να προσεγγίσετε άμεσα'; + + @override + String get noRecentChatters => 'Κανένας πρόσφατος συνομιλητής'; + + @override + String get public => 'Δημόσιο'; + + @override + String get failedToSendTicTacToeMove => 'Αποτυχία αποστολής κίνησης τρίλιζας'; + + @override + String get youWon => 'Κερδίσατε'; + + @override + String get opponentWon => 'Κέρδισε ο αντίπαλος'; + + @override + String get gameDraw => 'Ισοπαλία'; + + @override + String get yourTurn => 'Η σειρά σας'; + + @override + String get opponentTurn => 'Σειρά του αντιπάλου'; + + @override + String get pleaseEnterValidCoordinates => 'Εισαγάγετε έγκυρες συντεταγμένες'; + + @override + String get locationNotAvailable => 'Η τοποθεσία δεν είναι διαθέσιμη'; + + @override + String get customCaveMapPoint => 'Προσαρμοσμένο σημείο χάρτη σπηλαίου'; + + @override + String get mapPoint => 'Σημείο χάρτη'; + + @override + String get fetchingMissingImageFragments => 'Λήψη τμημάτων που λείπουν…'; + + @override + String get loadingImage => 'Φόρτωση…'; + + @override + String get receivingImage => 'Λήψη…'; + + @override + String get tapToLoad => 'Πατήστε για φόρτωση'; + + @override + String get connectDevice => 'Σύνδεση συσκευής'; + + @override + String get chooseTransportSubtitle => + 'Επιλέξτε μεταφορά Bluetooth, WiFi ή σειριακή'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Δίκτυο'; + + @override + String get serial => 'Σειριακή θύρα'; + + @override + String get scan => 'Σάρωση'; + + @override + String get pressScanToSearchForDevices => + 'Πατήστε «Σάρωση» για αναζήτηση κοντινών συσκευών'; + + @override + String failedToConnectToDevice(String name) { + return 'Αποτυχία σύνδεσης με $name'; + } + + @override + String get unknownDevice => 'Άγνωστη συσκευή'; + + @override + String get showingCachedResultsTapRefresh => + 'Εμφανίζονται αποτελέσματα από την προσωρινή μνήμη. Πατήστε ανανέωση για νέα σάρωση.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Σάρωση τοπικού δικτύου για συσκευές MeshCore WiFi στη θύρα $port'; + } + + @override + String get cancelScanAndAddServer => + 'Ακύρωση σάρωσης και προσθήκη διακομιστή'; + + @override + String get addServer => 'Προσθήκη διακομιστή'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Σάρωση... $scanned/$total διευθύνσεις IP'; + } + + @override + String get cancelAndEnterManually => 'Ακύρωση και χειροκίνητη εισαγωγή'; + + @override + String get noRecentOrDiscoveredServers => + 'Δεν υπάρχουν ακόμη πρόσφατοι ή εντοπισμένοι διακομιστές'; + + @override + String get recentlyUsed => 'Πρόσφατα χρησιμοποιημένοι'; + + @override + String get connecting => 'Γίνεται σύνδεση...'; + + @override + String get discoveredOnThisNetwork => 'Εντοπίστηκαν σε αυτό το δίκτυο'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'Ο διακομιστής στο $host:$port δεν είναι πλέον διαθέσιμος. Σαρώστε ξανά για να βρείτε ενεργούς διακομιστές.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Αποτυχία σύνδεσης με $host:$port'; + } + + @override + String get enterValidIpAddress => 'Εισαγάγετε έγκυρη διεύθυνση IP'; + + @override + String get enterValidTcpPort => 'Εισαγάγετε έγκυρη θύρα TCP'; + + @override + String get ipAddress => 'Διεύθυνση IP'; + + @override + String get tcpPort => 'Θύρα TCP'; + + @override + String get customServerPort => 'Προσαρμοσμένη θύρα διακομιστή'; + + @override + String get chat => 'Συνομιλία'; + + @override + String get restoringPreviousLink => 'Επαναφορά προηγούμενης σύνδεσης'; + + @override + String get noDeviceConnected => 'Δεν υπάρχει συνδεδεμένη συσκευή'; + + @override + String get sendAdvert => 'Αποστολή της επαφής μου'; + + @override + String get advertMode => 'Αποστολή της επαφής μου'; + + @override + String get advertModeSubtitle => + 'Επιλέξτε πόσο μακριά θα φτάσουν τα στοιχεία επαφής σας.'; + + @override + String get floodAdvertSent => 'Η επαφή στάλθηκε σε όλο το δίκτυο mesh'; + + @override + String get directAdvertSent => 'Η επαφή στάλθηκε σε κοντινές συσκευές'; +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..0b9e2d6 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,3420 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Messages'; + + @override + String get contacts => 'Contacts'; + + @override + String get map => 'Map'; + + @override + String get settings => 'Settings'; + + @override + String get connect => 'Connect'; + + @override + String get disconnect => 'Disconnect'; + + @override + String get noDevicesFound => 'No devices found'; + + @override + String get scanAgain => 'Scan Again'; + + @override + String get deviceNotConnected => 'Device not connected'; + + @override + String get locationPermissionDenied => 'Location permission denied'; + + @override + String get locationPermissionPermanentlyDenied => + 'Location permission permanently denied. Please enable in Settings.'; + + @override + String get locationPermissionRequired => + 'Location permission is required for GPS tracking and team coordination. You can enable it later in Settings.'; + + @override + String get locationServicesDisabled => + 'Location services are disabled. Please enable them in Settings.'; + + @override + String get failedToGetGpsLocation => 'Failed to get GPS location'; + + @override + String failedToAdvertise(String error) { + return 'Failed to advertise: $error'; + } + + @override + String get cancelReconnection => 'Cancel reconnection'; + + @override + String get general => 'General'; + + @override + String get theme => 'Theme'; + + @override + String get chooseTheme => 'Choose Theme'; + + @override + String get light => 'Light'; + + @override + String get dark => 'Dark'; + + @override + String get blueLightTheme => 'Blue light theme'; + + @override + String get blueDarkTheme => 'Blue dark theme'; + + @override + String get sarRed => 'SAR Red'; + + @override + String get alertEmergencyMode => 'Alert/Emergency mode'; + + @override + String get sarGreen => 'SAR Green'; + + @override + String get safeAllClearMode => 'Safe/All Clear mode'; + + @override + String get autoSystem => 'Auto (System)'; + + @override + String get followSystemTheme => 'Follow system theme'; + + @override + String get showRxTxIndicators => 'Show RX/TX Indicators'; + + @override + String get disableMap => 'Disable Map'; + + @override + String get language => 'Language'; + + @override + String get chooseLanguage => 'Choose Language'; + + @override + String get save => 'Save'; + + @override + String get cancel => 'Cancel'; + + @override + String get close => 'Close'; + + @override + String get about => 'About'; + + @override + String get appVersion => 'App Version'; + + @override + String get aboutMeshCoreSar => 'About MeshCore SAR'; + + @override + String get aboutDescription => + 'A Search & Rescue application designed for emergency response teams. Features include:\n\n• BLE mesh networking for device-to-device communication\n• Offline maps with multiple layer options\n• Real-time team member tracking\n• SAR tactical markers (found person, fire, staging)\n• Contact management and messaging\n• GPS tracking with compass heading\n• Map tile caching for offline use'; + + @override + String get technologiesUsed => 'Technologies Used:'; + + @override + String get technologiesList => + '• Flutter for cross-platform development\n• BLE (Bluetooth Low Energy) for mesh networking\n• OpenStreetMap for mapping\n• Provider for state management\n• SharedPreferences for local storage'; + + @override + String get moreInfo => 'More Info'; + + @override + String get packageName => 'Package Name'; + + @override + String get sampleData => 'Sample Data'; + + @override + String get sampleDataDescription => + 'Load or clear sample contacts, channel messages, and SAR markers for testing'; + + @override + String get loadSampleData => 'Load Sample Data'; + + @override + String get clearAllData => 'Clear All Data'; + + @override + String get clearAllDataConfirmTitle => 'Clear All Data'; + + @override + String get clearAllDataConfirmMessage => + 'This will clear all contacts and SAR markers. Are you sure?'; + + @override + String get clear => 'Clear'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Loaded $teamCount team members, $channelCount channels, $sarCount SAR markers, $messageCount messages'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Failed to load sample data: $error'; + } + + @override + String get allDataCleared => 'All data cleared'; + + @override + String get failedToStartBackgroundTracking => + 'Failed to start background tracking. Check permissions and BLE connection.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Location broadcast: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings.'; + + @override + String get noMessagesYet => 'No messages yet'; + + @override + String get pullDownToSync => 'Pull down to sync messages'; + + @override + String get deleteContact => 'Delete Contact'; + + @override + String get delete => 'Delete'; + + @override + String get viewOnMap => 'View on Map'; + + @override + String get refresh => 'Refresh'; + + @override + String get resetPath => 'Reset Path (Re-route)'; + + @override + String copiedToClipboard(String label) { + return '$label copied to clipboard'; + } + + @override + String get pleaseEnterPassword => 'Please enter a password'; + + @override + String failedToSyncContacts(String error) { + return 'Failed to sync contacts: $error'; + } + + @override + String get loggedInSuccessfully => + 'Logged in successfully! Waiting for room messages...'; + + @override + String get loginFailed => 'Login failed - incorrect password'; + + @override + String loggingIn(String roomName) { + return 'Logging in to $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Failed to send login: $error'; + } + + @override + String get lowLocationAccuracy => 'Low Location Accuracy'; + + @override + String get continue_ => 'Continue'; + + @override + String get sendSarMarker => 'Send SAR marker'; + + @override + String get deleteDrawing => 'Delete Drawing'; + + @override + String get drawingTools => 'Drawing Tools'; + + @override + String get drawLine => 'Draw Line'; + + @override + String get drawLineDesc => 'Draw a freehand line on the map'; + + @override + String get drawRectangle => 'Draw Rectangle'; + + @override + String get drawRectangleDesc => 'Draw a rectangular area on the map'; + + @override + String get measureDistance => 'Measure Distance'; + + @override + String get measureDistanceDesc => 'Long press two points to measure'; + + @override + String get clearMeasurement => 'Clear Measurement'; + + @override + String distanceLabel(String distance) { + return 'Distance: $distance'; + } + + @override + String get longPressForSecondPoint => 'Long press for second point'; + + @override + String get longPressToStartMeasurement => 'Long press to set first point'; + + @override + String get longPressToStartNewMeasurement => + 'Long press to start new measurement'; + + @override + String get shareDrawings => 'Share Drawings'; + + @override + String get clearAllDrawings => 'Clear All Drawings'; + + @override + String get completeLine => 'Complete Line'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Broadcast $count drawing$plural to team'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Remove all $count drawing$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Delete all $count drawing$plural from the map?'; + } + + @override + String get drawing => 'Drawing'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Share $count Drawing$plural'; + } + + @override + String get showReceivedDrawings => 'Show Received Drawings'; + + @override + String get showingAllDrawings => 'Showing all drawings'; + + @override + String get showingOnlyYourDrawings => 'Showing only your drawings'; + + @override + String get showSarMarkers => 'Show SAR Markers'; + + @override + String get showingSarMarkers => 'Showing SAR markers'; + + @override + String get hidingSarMarkers => 'Hiding SAR markers'; + + @override + String get clearAll => 'Clear All'; + + @override + String get publicChannel => 'Public Channel'; + + @override + String get broadcastToAll => 'Broadcast to all nearby nodes (ephemeral)'; + + @override + String get storedPermanently => 'Stored permanently in room'; + + @override + String get notConnectedToDevice => 'Not connected to device'; + + @override + String get typeYourMessage => 'Type your message...'; + + @override + String get quickLocationMarker => 'Quick location marker'; + + @override + String get markerType => 'Marker Type'; + + @override + String get sendTo => 'Send To'; + + @override + String get noDestinationsAvailable => 'No destinations available.'; + + @override + String get selectDestination => 'Select destination...'; + + @override + String get ephemeralBroadcastInfo => + 'Ephemeral: Broadcast over-the-air only. Not stored - nodes must be online.'; + + @override + String get persistentRoomInfo => + 'Persistent: Stored immutably in room. Synced automatically and preserved offline.'; + + @override + String get location => 'Location'; + + @override + String get fromMap => 'From Map'; + + @override + String get gettingLocation => 'Getting location...'; + + @override + String get locationError => 'Location Error'; + + @override + String get retry => 'Retry'; + + @override + String get refreshLocation => 'Refresh location'; + + @override + String accuracyMeters(int accuracy) { + return 'Accuracy: ±${accuracy}m'; + } + + @override + String get notesOptional => 'Notes (optional)'; + + @override + String get addAdditionalInformation => 'Add additional information...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'Location accuracy is ±${accuracy}m. This may not be accurate enough for SAR operations.\n\nContinue anyway?'; + } + + @override + String get loginToRoom => 'Login to Room'; + + @override + String get enterPasswordInfo => + 'Enter the password to access this room. The password will be saved for future use.'; + + @override + String get password => 'Password'; + + @override + String get enterRoomPassword => 'Enter room password'; + + @override + String get loggingInDots => 'Logging in...'; + + @override + String get login => 'Login'; + + @override + String failedToAddRoom(String error) { + return 'Failed to add room to device: $error\n\nThe room may not have advertised yet.\nTry waiting for the room to broadcast.'; + } + + @override + String get direct => 'Direct'; + + @override + String get flood => 'Flood'; + + @override + String get autoSend => 'Auto Send'; + + @override + String get autoSendDescription => 'Send via current path.'; + + @override + String get sendDirect => 'Send Direct'; + + @override + String get sendDirectDescription => 'Send directly to this contact.'; + + @override + String get sendFlood => 'Send Flood'; + + @override + String get sendFloodDescription => 'Send via all repeaters.'; + + @override + String get loggedIn => 'Logged In'; + + @override + String get noGpsData => 'No GPS data'; + + @override + String get distance => 'Distance'; + + @override + String directPingTimeout(String name) { + return 'Direct ping timeout - retrying $name with flooding...'; + } + + @override + String pingFailed(String name) { + return 'Ping failed to $name - no response received'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Are you sure you want to delete \"$name\"?\n\nThis will remove the contact from both the app and the companion radio device.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Failed to remove contact: $error'; + } + + @override + String get type => 'Type'; + + @override + String get publicKey => 'Public Key'; + + @override + String get lastSeen => 'Last Seen'; + + @override + String get yes => 'Yes'; + + @override + String get no => 'No'; + + @override + String get permissions => 'Permissions'; + + @override + String get telemetry => 'Telemetry'; + + @override + String get voltage => 'Voltage'; + + @override + String get battery => 'Battery'; + + @override + String get temperature => 'Temperature'; + + @override + String get humidity => 'Humidity'; + + @override + String get pressure => 'Pressure'; + + @override + String get gpsTelemetry => 'GPS (Telemetry)'; + + @override + String get reLoginToRoom => 'Re-Login to Room'; + + @override + String get heading => 'Heading'; + + @override + String get elevation => 'Elevation'; + + @override + String get accuracy => 'Accuracy'; + + @override + String get bearing => 'Bearing'; + + @override + String get direction => 'Direction'; + + @override + String get filterMarkers => 'Filter Markers'; + + @override + String get filterMarkersTooltip => 'Filter markers'; + + @override + String get contactsFilter => 'Contacts'; + + @override + String get repeatersFilter => 'Repeaters'; + + @override + String get sarMarkers => 'SAR Markers'; + + @override + String get foundPerson => 'Found Person'; + + @override + String get fire => 'Fire'; + + @override + String get stagingArea => 'Staging Area'; + + @override + String get showAll => 'Show All'; + + @override + String get locationUnavailable => 'Location unavailable'; + + @override + String get ahead => 'ahead'; + + @override + String degreesRight(int degrees) { + return '$degrees° right'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° left'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Lat: $latitude Lon: $longitude'; + } + + @override + String get noContactsYet => 'No contacts yet'; + + @override + String get connectToDeviceToLoadContacts => + 'Connect to a device to load contacts'; + + @override + String get teamMembers => 'Team Members'; + + @override + String get repeaters => 'Repeaters'; + + @override + String get rooms => 'Rooms'; + + @override + String get channels => 'Channels'; + + @override + String get selectMapLayer => 'Select Map Layer'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'ESRI Satellite'; + + @override + String get googleHybrid => 'Google Hybrid'; + + @override + String get googleRoadmap => 'Google Roadmap'; + + @override + String get googleTerrain => 'Google Terrain'; + + @override + String get dragToPosition => 'Drag to Position'; + + @override + String get createSarMarker => 'Create SAR Marker'; + + @override + String get compass => 'Compass'; + + @override + String get navigationAndContacts => 'Navigation & Contacts'; + + @override + String get sarAlert => 'SAR ALERT'; + + @override + String get textCopiedToClipboard => 'Text copied to clipboard'; + + @override + String get cannotReplySenderMissing => + 'Cannot reply: sender information missing'; + + @override + String get cannotReplyContactNotFound => 'Cannot reply: contact not found'; + + @override + String get copyText => 'Copy text'; + + @override + String get saveAsTemplate => 'Save as Template'; + + @override + String get templateSaved => 'Template saved successfully'; + + @override + String get templateAlreadyExists => 'Template with this emoji already exists'; + + @override + String get deleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmation => + 'Are you sure you want to delete this message?'; + + @override + String get shareLocation => 'Share location'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nCoordinates: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'SAR Location'; + + @override + String get justNow => 'Just now'; + + @override + String minutesAgo(int minutes) { + return '${minutes}m ago'; + } + + @override + String hoursAgo(int hours) { + return '${hours}h ago'; + } + + @override + String daysAgo(int days) { + return '${days}d ago'; + } + + @override + String secondsAgo(int seconds) { + return '${seconds}s ago'; + } + + @override + String get sending => 'Sending...'; + + @override + String get sent => 'Sent'; + + @override + String get delivered => 'Delivered'; + + @override + String deliveredWithTime(int time) { + return 'Delivered (${time}ms)'; + } + + @override + String get failed => 'Failed'; + + @override + String get broadcast => 'Broadcast'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Delivered to $delivered/$total contacts'; + } + + @override + String get allDelivered => 'All delivered'; + + @override + String get recipientDetails => 'Recipient Details'; + + @override + String get pending => 'Pending'; + + @override + String get sarMarkerFoundPerson => 'Found Person'; + + @override + String get sarMarkerFire => 'Fire Location'; + + @override + String get sarMarkerStagingArea => 'Staging Area'; + + @override + String get sarMarkerObject => 'Object Found'; + + @override + String get from => 'From'; + + @override + String get coordinates => 'Coordinates'; + + @override + String get tapToViewOnMap => 'Tap to view on map'; + + @override + String get radioSettings => 'Radio Settings'; + + @override + String get frequencyMHz => 'Frequency (MHz)'; + + @override + String get bandwidth => 'Bandwidth'; + + @override + String get spreadingFactor => 'Spreading Factor'; + + @override + String get codingRate => 'Coding Rate'; + + @override + String get txPowerDbm => 'TX Power (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Max: $power dBm'; + } + + @override + String get you => 'You'; + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String importFailed(String error) { + return 'Import failed: $error'; + } + + @override + String get unknown => 'Unknown'; + + @override + String get onlineLayers => 'Online Layers'; + + @override + String get locationTrail => 'Location Trail'; + + @override + String get showTrailOnMap => 'Show Trail on Map'; + + @override + String get trailVisible => 'Trail is visible on the map'; + + @override + String get trailHiddenRecording => 'Trail is hidden (still recording)'; + + @override + String get duration => 'Duration'; + + @override + String get points => 'Points'; + + @override + String get clearTrail => 'Clear Trail'; + + @override + String get clearTrailQuestion => 'Clear Trail?'; + + @override + String get clearTrailConfirmation => + 'Are you sure you want to clear the current location trail? This action cannot be undone.'; + + @override + String get noTrailRecorded => 'No trail recorded yet'; + + @override + String get startTrackingToRecord => + 'Start location tracking to record your trail'; + + @override + String get trailControls => 'Trail Controls'; + + @override + String get model => 'Model'; + + @override + String get version => 'Version'; + + @override + String get maxContacts => 'Max Contacts'; + + @override + String get maxChannels => 'Max Channels'; + + @override + String get publicInfo => 'Public Info'; + + @override + String get useCurrentLocation => 'Use current location'; + + @override + String get noneUnknown => 'None/Unknown'; + + @override + String get chatNode => 'Chat Node'; + + @override + String get repeater => 'Repeater'; + + @override + String get roomChannel => 'Room/Channel'; + + @override + String typeNumber(int number) { + return 'Type $number'; + } + + @override + String failedToSave(String error) { + return 'Failed to save: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Failed to get location: $error'; + } + + @override + String get sarTemplates => 'SAR Templates'; + + @override + String get addTemplate => 'Add Template'; + + @override + String get editTemplate => 'Edit Template'; + + @override + String get deleteTemplate => 'Delete Template'; + + @override + String get templateName => 'Template Name'; + + @override + String get templateNameHint => 'e.g. Found Person'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji is required'; + + @override + String get nameRequired => 'Name is required'; + + @override + String get templateDescription => 'Description (Optional)'; + + @override + String get templateDescriptionHint => 'Add additional context...'; + + @override + String get templateColor => 'Color'; + + @override + String get previewFormat => 'Preview (SAR Message Format)'; + + @override + String get importFromClipboard => 'Import'; + + @override + String get exportToClipboard => 'Export'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Delete template \'$name\'?'; + } + + @override + String get templateAdded => 'Template added'; + + @override + String get templateUpdated => 'Template updated'; + + @override + String get templateDeleted => 'Template deleted'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Imported $count templates', + one: 'Imported 1 template', + zero: 'No templates imported', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Exported $count templates to clipboard', + one: 'Exported 1 template to clipboard', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Reset to Defaults'; + + @override + String get resetToDefaultsConfirmation => + 'This will delete all custom templates and restore the 4 default templates. Continue?'; + + @override + String get reset => 'Reset'; + + @override + String get resetComplete => 'Templates reset to defaults'; + + @override + String get noTemplates => 'No templates available'; + + @override + String get tapAddToCreate => 'Tap + to create your first template'; + + @override + String get ok => 'OK'; + + @override + String get locationPermission => 'Location Permission'; + + @override + String get checking => 'Checking...'; + + @override + String get locationPermissionGrantedAlways => 'Granted (Always)'; + + @override + String get locationPermissionGrantedWhileInUse => 'Granted (While In Use)'; + + @override + String get locationPermissionDeniedTapToRequest => 'Denied - Tap to request'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Permanently Denied - Open Settings'; + + @override + String get locationPermissionDialogContent => + 'Location permission is permanently denied. Please enable it in your device settings to use GPS tracking and location sharing features.'; + + @override + String get openSettings => 'Open Settings'; + + @override + String get locationPermissionGranted => 'Location permission granted!'; + + @override + String get locationPermissionRequiredForGps => + 'Location permission is required for GPS tracking and location sharing.'; + + @override + String get locationPermissionAlreadyGranted => + 'Location permission is already granted.'; + + @override + String get sarNavyBlue => 'SAR Navy Blue'; + + @override + String get sarNavyBlueDescription => 'Professional/Operations Mode'; + + @override + String get selectRecipient => 'Select Recipient'; + + @override + String get broadcastToAllNearby => 'Broadcast to all nearby'; + + @override + String get searchRecipients => 'Search recipients...'; + + @override + String get noContactsFound => 'No contacts found'; + + @override + String get noRoomsFound => 'No rooms found'; + + @override + String get noRecipientsAvailable => 'No recipients available'; + + @override + String get noChannelsFound => 'No channels found'; + + @override + String get newMessage => 'New message'; + + @override + String get channel => 'Channel'; + + @override + String get samplePoliceLead => 'Police Lead'; + + @override + String get sampleDroneOperator => 'Drone Operator'; + + @override + String get sampleFirefighterAlpha => 'Firefighter'; + + @override + String get sampleMedicCharlie => 'Medic'; + + @override + String get sampleCommandDelta => 'Command'; + + @override + String get sampleFireEngine => 'Fire Engine'; + + @override + String get sampleAirSupport => 'Air Support'; + + @override + String get sampleBaseCoordinator => 'Base Coordinator'; + + @override + String get channelEmergency => 'Emergency'; + + @override + String get channelCoordination => 'Coordination'; + + @override + String get channelUpdates => 'Updates'; + + @override + String get sampleTeamMember => 'Sample Team Member'; + + @override + String get sampleScout => 'Sample Scout'; + + @override + String get sampleBase => 'Sample Base'; + + @override + String get sampleSearcher => 'Sample Searcher'; + + @override + String get sampleObjectBackpack => ' Backpack found - blue color'; + + @override + String get sampleObjectVehicle => ' Vehicle abandoned - check for owner'; + + @override + String get sampleObjectCamping => ' Camping equipment discovered'; + + @override + String get sampleObjectTrailMarker => ' Trail marker found off-path'; + + @override + String get sampleMsgAllTeamsCheckIn => 'All teams check in'; + + @override + String get sampleMsgWeatherUpdate => 'Weather update: Clear skies, temp 18°C'; + + @override + String get sampleMsgBaseCamp => 'Base camp established at staging area'; + + @override + String get sampleMsgTeamAlpha => 'Team moving to sector 2'; + + @override + String get sampleMsgRadioCheck => 'Radio check - all stations respond'; + + @override + String get sampleMsgWaterSupply => 'Water supply available at checkpoint 3'; + + @override + String get sampleMsgTeamBravo => 'Team reporting: sector 1 clear'; + + @override + String get sampleMsgEtaRallyPoint => 'ETA to rally point: 15 minutes'; + + @override + String get sampleMsgSupplyDrop => 'Supply drop confirmed for 14:00'; + + @override + String get sampleMsgDroneSurvey => 'Drone survey completed - no findings'; + + @override + String get sampleMsgTeamCharlie => 'Team requesting backup'; + + @override + String get sampleMsgRadioDiscipline => 'All units: maintain radio discipline'; + + @override + String get sampleMsgUrgentMedical => + 'URGENT: Medical assistance needed at sector 4'; + + @override + String get sampleMsgAdultMale => ' Adult male, conscious'; + + @override + String get sampleMsgFireSpotted => 'Fire spotted - coordinates incoming'; + + @override + String get sampleMsgSpreadingRapidly => ' Spreading rapidly!'; + + @override + String get sampleMsgPriorityHelicopter => 'PRIORITY: Need helicopter support'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Medical team en route to your location'; + + @override + String get sampleMsgEvacHelicopter => 'Evac helicopter ETA 10 minutes'; + + @override + String get sampleMsgEmergencyResolved => 'Emergency resolved - all clear'; + + @override + String get sampleMsgEmergencyStagingArea => ' Emergency staging area'; + + @override + String get sampleMsgEmergencyServices => + 'Emergency services notified and responding'; + + @override + String get sampleAlphaTeamLead => 'Team Lead'; + + @override + String get sampleBravoScout => 'Scout'; + + @override + String get sampleCharlieMedic => 'Medic'; + + @override + String get sampleDeltaNavigator => 'Navigator'; + + @override + String get sampleEchoSupport => 'Support'; + + @override + String get sampleBaseCommand => 'Base Command'; + + @override + String get sampleFieldCoordinator => 'Field Coordinator'; + + @override + String get sampleMedicalTeam => 'Medical Team'; + + @override + String get mapDrawing => 'Map Drawing'; + + @override + String get navigateToDrawing => 'Navigate to Drawing'; + + @override + String get copyCoordinates => 'Copy Coordinates'; + + @override + String get hideFromMap => 'Hide from Map'; + + @override + String get lineDrawing => 'Line Drawing'; + + @override + String get rectangleDrawing => 'Rectangle Drawing'; + + @override + String get manualCoordinates => 'Manual Coordinates'; + + @override + String get enterCoordinatesManually => 'Enter coordinates manually'; + + @override + String get latitudeLabel => 'Latitude'; + + @override + String get longitudeLabel => 'Longitude'; + + @override + String get exampleCoordinates => 'Example: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Share Drawing'; + + @override + String get shareWithAllNearbyDevices => 'Share with all nearby devices'; + + @override + String get shareToRoom => 'Share to Room'; + + @override + String get sendToPersistentStorage => 'Send to persistent room storage'; + + @override + String get deleteDrawingConfirm => + 'Are you sure you want to delete this drawing?'; + + @override + String get drawingDeleted => 'Drawing deleted'; + + @override + String yourDrawingsCount(int count) { + return 'Your Drawings ($count)'; + } + + @override + String get shared => 'Shared'; + + @override + String get line => 'Line'; + + @override + String get rectangle => 'Rectangle'; + + @override + String get updateAvailable => 'Update Available'; + + @override + String get currentVersion => 'Current'; + + @override + String get latestVersion => 'Latest'; + + @override + String get downloadUpdate => 'Download'; + + @override + String get updateLater => 'Later'; + + @override + String get cadastralParcels => 'Cadastral Parcels'; + + @override + String get forestRoads => 'Forest Roads'; + + @override + String get wmsOverlays => 'WMS Overlays'; + + @override + String get hikingTrails => 'Hiking Trails'; + + @override + String get mainRoads => 'Main Roads'; + + @override + String get houseNumbers => 'House Numbers'; + + @override + String get fireHazardZones => 'Fire Hazard Zones'; + + @override + String get historicalFires => 'Historical Fires'; + + @override + String get firebreaks => 'Firebreaks'; + + @override + String get krasFireZones => 'Kras Fire Zones'; + + @override + String get placeNames => 'Place Names'; + + @override + String get municipalityBorders => 'Municipality Borders'; + + @override + String get topographicMap => 'Topographic Map 1:25000'; + + @override + String get recentMessages => 'Recent Messages'; + + @override + String get addChannel => 'Add Channel'; + + @override + String get channelName => 'Channel Name'; + + @override + String get channelNameHint => 'e.g., Rescue Team Alpha'; + + @override + String get channelSecret => 'Channel Secret'; + + @override + String get channelSecretHint => 'Shared password for this channel'; + + @override + String get channelSecretHelp => + 'This secret must be shared with all team members who need access to this channel'; + + @override + String get channelTypesInfo => + 'Hash channels (#team): Secret auto-generated from name. Same name = same channel across devices.\n\nPrivate channels: Use explicit secret. Only those with the secret can join.'; + + @override + String get hashChannelInfo => + 'Hash channel: Secret will be auto-generated from the channel name. Anyone using the same name will join the same channel.'; + + @override + String get channelNameRequired => 'Channel name is required'; + + @override + String get channelNameTooLong => 'Channel name must be 31 characters or less'; + + @override + String get channelSecretRequired => 'Channel secret is required'; + + @override + String get channelSecretTooLong => + 'Channel secret must be 32 characters or less'; + + @override + String get invalidAsciiCharacters => 'Only ASCII characters are allowed'; + + @override + String get channelCreatedSuccessfully => 'Channel created successfully'; + + @override + String channelCreationFailed(String error) { + return 'Failed to create channel: $error'; + } + + @override + String get deleteChannel => 'Delete Channel'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Are you sure you want to delete channel \"$channelName\"? This action cannot be undone.'; + } + + @override + String get channelDeletedSuccessfully => 'Channel deleted successfully'; + + @override + String channelDeletionFailed(String error) { + return 'Failed to delete channel: $error'; + } + + @override + String get createChannel => 'Create Channel'; + + @override + String get wizardBack => 'Back'; + + @override + String get wizardSkip => 'Skip'; + + @override + String get wizardNext => 'Next'; + + @override + String get wizardGetStarted => 'Get Started'; + + @override + String get wizardWelcomeTitle => 'Welcome to MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'View Welcome Tutorial'; + + @override + String get allTeamContacts => 'All Team Contacts'; + + @override + String directMessagesInfo(int count) { + return 'Direct messages with ACKs. Sent to $count team members.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'SAR marker sent to $count contacts'; + } + + @override + String get noContactsAvailable => 'No team contacts available'; + + @override + String get reply => 'Reply'; + + @override + String get technicalDetails => 'Details'; + + @override + String get messageTechnicalDetails => 'Message details'; + + @override + String get linkQuality => 'Link quality'; + + @override + String get delivery => 'Delivery'; + + @override + String get status => 'Status'; + + @override + String get expectedAckTag => 'Expected ACK tag'; + + @override + String get roundTrip => 'Round-trip'; + + @override + String get retryAttempt => 'Retry attempt'; + + @override + String get floodFallback => 'Flood fallback'; + + @override + String get identity => 'Identity'; + + @override + String get messageId => 'Message ID'; + + @override + String get sender => 'Sender'; + + @override + String get senderKey => 'Sender key'; + + @override + String get recipient => 'Recipient'; + + @override + String get recipientKey => 'Recipient key'; + + @override + String get voice => 'Voice'; + + @override + String get voiceId => 'Voice ID'; + + @override + String get envelope => 'Envelope'; + + @override + String get sessionProgress => 'Session progress'; + + @override + String get complete => 'Complete'; + + @override + String get rawDump => 'Raw dump'; + + @override + String get cannotRetryMissingRecipient => + 'Cannot retry: recipient information missing'; + + @override + String get voiceUnavailable => 'Voice unavailable right now'; + + @override + String get requestingVoice => 'Requesting voice'; + + @override + String get device => 'device'; + + @override + String get change => 'Change'; + + @override + String get wizardOverviewDescription => + 'This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.'; + + @override + String get wizardOverviewFeature1 => + 'Send direct, room, and channel messages from the main Messages tab.'; + + @override + String get wizardOverviewFeature2 => + 'Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.'; + + @override + String get wizardOverviewFeature3 => + 'Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.'; + + @override + String get wizardMessagingTitle => 'Messaging and Field Reports'; + + @override + String get wizardMessagingDescription => + 'Messages are more than plain text here. The app already supports several operational payloads and transfer workflows.'; + + @override + String get wizardMessagingFeature1 => + 'Send direct messages, room posts, and channel traffic from one composer.'; + + @override + String get wizardMessagingFeature2 => + 'Create SAR updates and reusable SAR templates for common field reports.'; + + @override + String get wizardMessagingFeature3 => + 'Transfer voice sessions and images, with progress and airtime estimates in the UI.'; + + @override + String get wizardConnectDeviceTitle => 'Connect device'; + + @override + String get wizardConnectDeviceDescription => + 'Connect your MeshCore radio, choose a name, and apply a radio preset before continuing.'; + + @override + String get wizardSetupBadge => 'Setup'; + + @override + String get wizardOverviewBadge => 'Overview'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Connected to $deviceName'; + } + + @override + String get wizardNoDeviceConnected => 'No device connected yet'; + + @override + String get wizardSkipForNow => 'Skip for now'; + + @override + String get wizardDeviceNameLabel => 'Device name'; + + @override + String get wizardDeviceNameHelp => + 'This name is advertised to other MeshCore users.'; + + @override + String get wizardConfigRegionLabel => 'Config region'; + + @override + String get wizardConfigRegionHelp => + 'Uses the full official MeshCore preset list. Default is EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Make sure the selected preset matches your local radio regulations.'; + + @override + String get wizardPresetNote2 => + 'The list matches the official MeshCore config tool preset feed.'; + + @override + String get wizardPresetNote3 => + 'EU/UK (Narrow) stays selected by default for onboarding.'; + + @override + String get wizardSaving => 'Saving...'; + + @override + String get wizardSaveAndContinue => 'Save and continue'; + + @override + String get wizardEnterDeviceName => 'Enter a device name before continuing.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return 'Saved $deviceName with $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Contacts, Rooms, and Repeaters'; + + @override + String get wizardNetworkDescription => + 'The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.'; + + @override + String get wizardNetworkFeature1 => + 'Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.'; + + @override + String get wizardNetworkFeature2 => + 'Use smart ping, room login, learned paths, and route reset tools when connectivity gets messy.'; + + @override + String get wizardNetworkFeature3 => + 'Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.'; + + @override + String get wizardMapOpsTitle => 'Map, Trails, and Shared Geometry'; + + @override + String get wizardMapOpsDescription => + 'The app map is tied directly into messaging, tracking, and SAR overlays instead of being a separate viewer.'; + + @override + String get wizardMapOpsFeature1 => + 'Track your own position, teammate locations, and share live location to a private channel from the channel menu.'; + + @override + String get wizardMapOpsFeature2 => + 'Open drawings from messages, preview them inline, and remove them from the map when needed.'; + + @override + String get wizardMapOpsFeature3 => + 'Use repeater map views and shared overlays to understand network reach in the field.'; + + @override + String get wizardToolsTitle => 'Tools Beyond Messaging'; + + @override + String get wizardToolsDescription => + 'There is more here than the four main tabs. The app also includes configuration, diagnostics, and optional sensor workflows.'; + + @override + String get wizardToolsFeature1 => + 'Open device config to change radio settings, telemetry, TX power, and companion details.'; + + @override + String get wizardToolsFeature2 => + 'Enable the Sensors tab when you want watched sensor dashboards and quick refresh actions.'; + + @override + String get wizardToolsFeature3 => + 'Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'; + + @override + String get postConnectDiscoveryTitle => 'Discover repeaters now?'; + + @override + String get postConnectDiscoveryDescription => + 'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.'; + + @override + String get contactInSensors => 'In Sensors'; + + @override + String get contactAddToSensors => 'Add to Sensors'; + + @override + String get contactSetPath => 'Set path'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName added to Sensors'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Failed to clear route: $error'; + } + + @override + String get contactRouteCleared => 'Route cleared'; + + @override + String contactRouteSet(String route) { + return 'Route set: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Failed to set route: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'ACK timeout'; + + @override + String get opcode => 'Opcode'; + + @override + String get payload => 'Payload'; + + @override + String get hops => 'Hops'; + + @override + String get hashSize => 'Hash size'; + + @override + String get pathBytes => 'Path bytes'; + + @override + String get selectedPath => 'Selected path'; + + @override + String get estimatedTx => 'Estimated tx'; + + @override + String get senderToReceipt => 'Sender to receipt'; + + @override + String get receivedCopies => 'Received copies'; + + @override + String get retryCause => 'Retry cause'; + + @override + String get retryMode => 'Retry mode'; + + @override + String get retryResult => 'Retry result'; + + @override + String get lastRetry => 'Last retry'; + + @override + String get rxPackets => 'RX packets'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Rate'; + + @override + String get window => 'Window'; + + @override + String get posttxDelay => 'Post-tx delay'; + + @override + String get bandpass => 'Band-pass'; + + @override + String get bandpassFilterVoice => 'Band-pass filter voice'; + + @override + String get active => 'Active'; + + @override + String get addContact => 'Add Contact'; + + @override + String get all => 'All'; + + @override + String get clearAllLabel => 'Clear all'; + + @override + String get clearFilters => 'Clear filters'; + + @override + String get clearRoute => 'Clear Route'; + + @override + String get clearMessages => 'Clear Messages'; + + @override + String get clearScale => 'Clear scale'; + + @override + String get clearDiscoveries => 'Clear discoveries'; + + @override + String get clearOnlineTraceDatabase => 'Clear online trace database'; + + @override + String get clearAllChannels => 'Clear all channels'; + + @override + String get clearAllContacts => 'Clear all contacts'; + + @override + String get clearChannels => 'Clear channels'; + + @override + String get clearContacts => 'Clear contacts'; + + @override + String get clearPathOnMaxRetry => 'Clear path on max retry'; + + @override + String get create => 'Create'; + + @override + String get custom => 'Custom'; + + @override + String get defaultValue => 'Default'; + + @override + String get duplicate => 'Duplicate'; + + @override + String get editName => 'Edit name'; + + @override + String get open => 'Open'; + + @override + String get paste => 'Paste'; + + @override + String get preview => 'Preview'; + + @override + String get remove => 'Remove'; + + @override + String get rename => 'Rename'; + + @override + String get resolveAll => 'Resolve all'; + + @override + String get send => 'Send'; + + @override + String get sendAnyway => 'Send anyway'; + + @override + String get share => 'Share'; + + @override + String get trace => 'Trace'; + + @override + String get discovery => 'Discovery'; + + @override + String get discoverRepeaters => 'Discover repeaters'; + + @override + String get discoverSensors => 'Discover sensors'; + + @override + String get repeaterDiscoverySent => 'Repeater discovery sent'; + + @override + String get sensorDiscoverySent => 'Sensor discovery sent'; + + @override + String get clearedPendingDiscoveries => 'Cleared pending discoveries.'; + + @override + String get autoDiscovery => 'Auto discovery'; + + @override + String get enableAutomaticAdding => 'Enable automatic adding'; + + @override + String get autoaddRepeaters => 'Auto-add repeaters'; + + @override + String get autoaddRoomServers => 'Auto-add room servers'; + + @override + String get autoaddSensors => 'Auto-add sensors'; + + @override + String get autoaddUsers => 'Auto-add users'; + + @override + String get overwriteOldestWhenFull => 'Overwrite oldest when full'; + + @override + String get storage => 'Storage'; + + @override + String get dangerZone => 'Danger zone'; + + @override + String get profiles => 'Profiles'; + + @override + String get favourites => 'Favourites'; + + @override + String get sensors => 'Sensors'; + + @override + String get others => 'Others'; + + @override + String get gpsModule => 'GPS Module'; + + @override + String get liveTraffic => 'Live Traffic'; + + @override + String get repeatersMap => 'Repeaters Map'; + + @override + String get blePacketLogs => 'BLE Packet Logs'; + + @override + String get onlineTraceDatabase => 'Online trace database'; + + @override + String get routePathByteSize => 'Route path byte size'; + + @override + String get messageNotifications => 'Message notifications'; + + @override + String get sarAlerts => 'SAR alerts'; + + @override + String get discoveryNotifications => 'Discovery notifications'; + + @override + String get updateNotifications => 'Update notifications'; + + @override + String get muteWhileAppIsOpen => 'Mute while app is open'; + + @override + String get disableContacts => 'Disable Contacts'; + + @override + String get enableSensorsTab => 'Enable Sensors tab'; + + @override + String get enableProfiles => 'Enable Profiles'; + + @override + String get nearestRepeaterFallback => 'Nearest repeater fallback'; + + @override + String get deleteAllStoredMessageHistory => + 'Delete all stored message history'; + + @override + String get messageFontSize => 'Message font size'; + + @override + String get rotateMapWithHeading => 'Rotate map with heading'; + + @override + String get showMapDebugInfo => 'Show map debug info'; + + @override + String get openMapInFullscreen => 'Open map in fullscreen'; + + @override + String get showSarMarkersLabel => 'Show SAR markers'; + + @override + String get hideRepeatersOnMap => 'Hide repeaters on map'; + + @override + String get setMapScale => 'Set map scale'; + + @override + String get customMapScaleSaved => 'Custom map scale saved'; + + @override + String get voiceBitrate => 'Voice bitrate'; + + @override + String get voiceCompressor => 'Voice compressor'; + + @override + String get voiceLimiter => 'Voice limiter'; + + @override + String get micAutoGain => 'Mic auto gain'; + + @override + String get echoCancellation => 'Echo cancellation'; + + @override + String get noiseSuppression => 'Noise suppression'; + + @override + String get trimSilenceInVoiceMessages => 'Trim silence in voice messages'; + + @override + String get compressor => 'Compressor'; + + @override + String get limiter => 'Limiter'; + + @override + String get autoGain => 'Auto gain'; + + @override + String get echoCancel => 'Echo cancel'; + + @override + String get noiseSuppress => 'Noise suppress'; + + @override + String get silenceTrim => 'Silence trim'; + + @override + String get maxImageSize => 'Max image size'; + + @override + String get imageCompression => 'Image compression'; + + @override + String get grayscale => 'Grayscale'; + + @override + String get ultraMode => 'Ultra mode'; + + @override + String get fastPrivateGpsUpdates => 'Fast private GPS updates'; + + @override + String get movementThreshold => 'Movement threshold'; + + @override + String get fastGpsMovementThreshold => 'Fast GPS movement threshold'; + + @override + String get fastGpsActiveuseInterval => 'Fast GPS active-use interval'; + + @override + String get activeuseUpdateInterval => 'Active-use update interval'; + + @override + String get repeatNearbyTraffic => 'Repeat nearby traffic'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Relay through repeaters across the mesh'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Nearby only, without repeater flooding'; + + @override + String get multihop => 'Multi-hop'; + + @override + String get createProfile => 'Create Profile'; + + @override + String get renameProfile => 'Rename Profile'; + + @override + String get newProfile => 'New Profile'; + + @override + String get manageProfiles => 'Manage profiles'; + + @override + String get enableProfilesToStartManagingThem => + 'Enable profiles to start managing them.'; + + @override + String get openMessage => 'Open message'; + + @override + String get jumpToTheRelatedSarMessage => 'Jump to the related SAR message'; + + @override + String get removeSarMarker => 'Remove SAR marker'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Please select a destination to send SAR marker'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'SAR marker broadcast to public channel'; + + @override + String get sarMarkerSentToRoom => 'SAR marker sent to room'; + + @override + String get loadFromGallery => 'Load from gallery'; + + @override + String get replaceImage => 'Replace image'; + + @override + String get selectFromGallery => 'Select from gallery'; + + @override + String get team => 'Team'; + + @override + String get found => 'Found'; + + @override + String get staging => 'Staging'; + + @override + String get object => 'Object'; + + @override + String get quiet => 'Quiet'; + + @override + String get busy => 'Busy'; + + @override + String get searchMessages => 'Search messages'; + + @override + String get sendImageFromGallery => 'Send image from gallery'; + + @override + String get takePhoto => 'Take photo'; + + @override + String get allMessages => 'All messages'; + + @override + String get sendToPublicChannel => 'Send to Public Channel?'; + + @override + String get selectMarkerTypeAndDestination => + 'Select marker type and destination'; + + @override + String get noDestinationsAvailableLabel => 'No destinations available'; + + @override + String get image => 'Image'; + + @override + String get format => 'Format'; + + @override + String get dimensions => 'Dimensions'; + + @override + String get segments => 'Segments'; + + @override + String get transfers => 'Transfers'; + + @override + String get downloadedBy => 'Downloaded by'; + + @override + String get saveDiscoverySettings => 'Save discovery settings'; + + @override + String get savePublicInfo => 'Save public info'; + + @override + String get saveRadioSettings => 'Save radio settings'; + + @override + String get savePath => 'Save Path'; + + @override + String get wipeDeviceData => 'Wipe device data'; + + @override + String get wipeDevice => 'Wipe device'; + + @override + String get destructiveDeviceActions => 'Destructive device actions.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Choose a preset or fine-tune custom radio settings.'; + + @override + String get used => 'Used'; + + @override + String get total => 'Total'; + + @override + String get renameValue => 'Rename value'; + + @override + String get customizeFields => 'Customize fields'; + + @override + String get livePreview => 'Live preview'; + + @override + String get refreshSchedule => 'Refresh schedule'; + + @override + String get noResponse => 'No response'; + + @override + String get refreshing => 'Refreshing'; + + @override + String get unavailable => 'Unavailable'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Pick a relay or node to watch in Sensors.'; + + @override + String get publicKeyLabel => 'Public key'; + + @override + String get alreadyInContacts => 'Already in contacts'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Connect to a device before adding contacts'; + + @override + String get fromContacts => 'From contacts'; + + @override + String get onlineOnly => 'Online only'; + + @override + String get inBoth => 'In both'; + + @override + String get source => 'Source'; + + @override + String get allMessagesCleared => 'All messages cleared'; + + @override + String get onlineTraceDatabaseCleared => 'Online trace database cleared'; + + @override + String get packetLogsCleared => 'Packet logs cleared'; + + @override + String get hexDataCopiedToClipboard => 'Hex data copied to clipboard'; + + @override + String get developerModeEnabled => 'Developer mode enabled'; + + @override + String get developerModeDisabled => 'Developer mode disabled'; + + @override + String get clipboardIsEmpty => 'Clipboard is empty'; + + @override + String get contactImported => 'Contact imported'; + + @override + String get contactLinkCopiedToClipboard => 'Contact link copied to clipboard'; + + @override + String get failedToExportContact => 'Failed to export contact'; + + @override + String get noLogsToExport => 'No logs to export'; + + @override + String get exportAsCsv => 'Export as CSV'; + + @override + String get exportAsText => 'Export as Text'; + + @override + String get receivedRfc3339 => 'Received (RFC3339)'; + + @override + String get buildTime => 'Build Time'; + + @override + String get downloadUrlNotAvailable => 'Download URL not available'; + + @override + String get cannotOpenDownloadUrl => 'Cannot open download URL'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'Update check is only available on Android'; + + @override + String get youAreRunningTheLatestVersion => + 'You are running the latest version'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Update available but download URL not found'; + + @override + String get startTictactoe => 'Start Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe unavailable'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: opponent unknown'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: waiting for start'; + + @override + String get acceptsShareLinks => 'Accepts share links'; + + @override + String get supportsRawHex => 'Supports raw hex'; + + @override + String get clipboardfriendly => 'Clipboard-friendly'; + + @override + String get captured => 'Captured'; + + @override + String get size => 'Size'; + + @override + String get noCustomChannelsToClear => 'No custom channels to clear.'; + + @override + String get noDeviceContactsToClear => 'No device contacts to clear.'; + + @override + String get aToZ => 'A-Z'; + + @override + String get add => 'Add'; + + @override + String get addAdditionalDetails => 'Add additional details'; + + @override + String get addPeer => 'Add Peer'; + + @override + String get addPeerManually => 'Add peer manually'; + + @override + String addedToContacts(String name) { + return '$name added to contacts'; + } + + @override + String addedToSensors(String name) { + return '$name added to Sensors'; + } + + @override + String get allowAll => 'Allow all'; + + @override + String get anonymousRxStats => 'Anonymous RX stats'; + + @override + String get appearance => 'Appearance'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Base telemetry'; + + @override + String get blePin => 'BLE PIN'; + + @override + String get buzzerAlerts => 'Buzzer alerts'; + + @override + String get cancelImageReceive => 'Cancel image receive'; + + @override + String get choose => 'Choose'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Choose which tabs and contact sections to show'; + + @override + String get clearCache => 'Clear cache'; + + @override + String get clearHistory => 'Clear history'; + + @override + String get clearLiveView => 'Clear live view'; + + @override + String get clearLogs => 'Clear logs'; + + @override + String get clearOfflineCache => 'Clear offline cache?'; + + @override + String get clearOverlay => 'Clear overlay'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Clear route only after all retries fail'; + + @override + String get codecBitrateAndAudioProcessing => + 'Codec, bitrate, and audio processing'; + + @override + String get connectByIpAddress => 'Connect by IP address'; + + @override + String get controlWhichAlertsYouReceive => 'Control which alerts you receive'; + + @override + String get copyFullHex => 'Copy full hex'; + + @override + String get copyRawResponse => 'Copy raw response'; + + @override + String get couldNotLoadMetHistory => 'Could not load MET history'; + + @override + String customizeSensor(String name) { + return 'Customize $name'; + } + + @override + String get data => 'Data'; + + @override + String get deleteGroup => 'Delete group'; + + @override + String deleteStyleConfirm(String name) { + return 'Delete $name?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Delete this $type?'; + } + + @override + String get deny => 'Deny'; + + @override + String get deviceInfo => 'Device info'; + + @override + String get deviceSettings => 'Device Settings'; + + @override + String get deviceTimeSynced => 'Device time synced to this phone.'; + + @override + String get disableFastGpsPublishing => 'Disable fast GPS publishing'; + + @override + String get displayMarkersAndTraceDatabase => + 'Display, markers, and trace database'; + + @override + String get download => 'Download'; + + @override + String get environmentalTelemetry => 'Environmental telemetry'; + + @override + String errorCheckingForUpdates(String error) { + return 'Error checking for updates: $error'; + } + + @override + String errorGeneric(String error) { + return 'Error: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Error opening download: $error'; + } + + @override + String get exportLogs => 'Export logs'; + + @override + String get extraAggressiveAvifCompression => + 'Extra-aggressive AVIF compression'; + + @override + String failedToAddContact(String error) { + return 'Failed to add contact: $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Failed to add $name: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Failed to clear channels: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Failed to clear contacts: $error'; + } + + @override + String get failedToConnectViaSerial => 'Failed to connect via serial'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Failed to load preview image: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Failed to load trace: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Failed to send SAR marker: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Failed to set buzzer mode: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Failed to set GPS mode: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Failed to sync device time: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Failed to wipe device data: $error'; + } + + @override + String get fastGpsTargetChannel => 'Fast GPS target channel'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Fast updates, thresholds, and permissions'; + + @override + String get fetchingCatalog => 'Fetching catalog...'; + + @override + String get fifteenMinutes => '15 minutes'; + + @override + String get fifteenSeconds => '15 seconds'; + + @override + String get filterByDirection => 'Filter by direction'; + + @override + String get finish => 'Finish'; + + @override + String get fiveMinutes => '5 minutes'; + + @override + String get fiveSeconds => '5 seconds'; + + @override + String get fix => 'Fix'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'GPS advert policy'; + + @override + String get gpsAndLocation => 'GPS & Location'; + + @override + String get gpsDiagnostics => 'GPS diagnostics'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Help'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Hidden'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'How the radio auto-adds discovered nodes.'; + + @override + String get images => 'Images'; + + @override + String get importProfile => 'Import profile'; + + @override + String get lastFix => 'Last fix'; + + @override + String get latest => 'Latest'; + + @override + String get latitude => 'Latitude'; + + @override + String get loadASavedRegion => 'Load a saved region'; + + @override + String get loading => 'Loading'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Location sharing, hardware, and update interval.'; + + @override + String get locationTelemetry => 'Location telemetry'; + + @override + String get lockDestination => 'Lock destination'; + + @override + String get longitude => 'Longitude'; + + @override + String get mapStyle => 'Map Style'; + + @override + String get max => 'Max'; + + @override + String get maxZoom => 'Max Zoom'; + + @override + String get messaging => 'Messaging'; + + @override + String get metHistory => 'MET history'; + + @override + String get min => 'Min'; + + @override + String get minZoom => 'Min Zoom'; + + @override + String get moveDown => 'Move down'; + + @override + String get moveUp => 'Move up'; + + @override + String get multiAckMode => 'Multi-ACK mode'; + + @override + String get multiDeviceWorkspaceManagement => + 'Multi-device workspace management'; + + @override + String get myLocation => 'My location'; + + @override + String get nameAndTelemetryShared => + 'Name and telemetry shared with other devices.'; + + @override + String newVersionAvailable(String version) { + return 'New version: $version'; + } + + @override + String get newer => 'Newer'; + + @override + String get noNeighboursFound => 'No neighbours found'; + + @override + String get noServersFound => 'No servers found'; + + @override + String get notSet => 'Not set'; + + @override + String get notifications => 'Notifications'; + + @override + String get offZeroSeconds => 'Off (0 s)'; + + @override + String get offlineMaps => 'Offline Maps'; + + @override + String get older => 'Older'; + + @override + String get oneByteMode0 => '1 byte (mode 0)'; + + @override + String get oneHour => '1 hour'; + + @override + String get oneMinute => '1 minute'; + + @override + String get openPacketLogs => 'Open packet logs'; + + @override + String get packetTypeHelp => 'Packet type help'; + + @override + String get pasteShareLinkOrHexAdvert => 'Paste a share link or hex advert'; + + @override + String get pathHash => 'Path hash'; + + @override + String get pathSize => 'Path size'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Polygon'; + + @override + String get rawResponseCopied => 'Raw response copied'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Resend through nearest repeater on failure'; + + @override + String get resolveContact => 'Resolve contact'; + + @override + String get routingRetriesAndDestinationLock => + 'Routing, retries, and destination lock'; + + @override + String get samples => 'Samples'; + + @override + String get satellites => 'Satellites'; + + @override + String get saving => 'Saving...'; + + @override + String get search => 'Search'; + + @override + String get searchByNameOrToken => 'Search by name or token'; + + @override + String get searchDiscoveredNodes => 'Search discovered nodes'; + + @override + String get searchInCurrentFilter => 'Search in current filter'; + + @override + String get searchLogs => 'Search logs...'; + + @override + String get searchSensors => 'Search sensors'; + + @override + String get searchThisSection => 'Search this section'; + + @override + String get selectedRelays => 'Selected relays'; + + @override + String get sensor => 'Sensor'; + + @override + String serialError(String error) { + return 'Serial error: $error'; + } + + @override + String setPathForContact(String name) { + return 'Set Path for $name'; + } + + @override + String get shareLiveGps => 'Share live GPS'; + + @override + String get shareMyTiles => 'Share my tiles'; + + @override + String get sharingLocationFromPhone => + 'Sharing location on this channel from the phone.'; + + @override + String get sharingLocationFromRadio => + 'Sharing location on this channel from the radio.'; + + @override + String signalDbm(String rssi) { + return 'Signal $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => 'Size, compression, and preview'; + + @override + String get simpleMode => 'Simple mode'; + + @override + String get simpleModeDescription => + 'Only chat and map with favourite contacts'; + + @override + String get simpleModeRequiresLockedDestination => + 'Enable \"Lock destination\" with a channel first to use Simple mode'; + + @override + String get noFavouriteContacts => 'No favourite contacts'; + + @override + String get skip => 'Skip'; + + @override + String get smallerFileSize => 'Smaller file size'; + + @override + String get sort => 'Sort'; + + @override + String get stoppedSharingLocation => + 'Stopped sharing location on this channel.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Suppress notifications while in foreground'; + + @override + String get tabsAndNavigation => 'Tabs & Navigation'; + + @override + String get templatesAndTutorials => 'Templates and tutorials'; + + @override + String get tenMinutes => '10 minutes'; + + @override + String get tenSeconds => '10 seconds'; + + @override + String get testSendUpdate => 'Test send update'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Theme, language, and display preferences'; + + @override + String get thirtyMinutes => '30 minutes'; + + @override + String get thirtySeconds => '30 seconds'; + + @override + String get threeBytesMode2 => '3 bytes (mode 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Traffic stats, message history, and developer tools'; + + @override + String get twoBytesMode1 => '2 bytes (mode 1)'; + + @override + String get twoMinutes => '2 minutes'; + + @override + String get undo => 'Undo'; + + @override + String get updated => 'Updated'; + + @override + String get useContactFlags => 'Use contact flags'; + + @override + String get useSavedCoordinates => 'Use saved coordinates'; + + @override + String get view => 'View'; + + @override + String get viewNeighbours => 'View Neighbours'; + + @override + String get viewPublicStats => 'View public stats'; + + @override + String get setRegionScope => 'Set region scope'; + + @override + String get regionScope => 'Region scope'; + + @override + String get regionScopeNone => 'None (global)'; + + @override + String get clearRegionScope => 'Clear scope'; + + @override + String get regionScopeWarning => + 'Only repeaters allowing this region will forward.'; + + @override + String get discoverRegions => 'Discover from repeaters'; + + @override + String get discoveringRegions => 'Discovering regions...'; + + @override + String get enterRegionName => 'Region name (e.g. auckland)'; + + @override + String get noRegionsFound => 'No regions found on this repeater.'; + + @override + String regionScopeSet(String name) { + return 'Region scope set to $name'; + } + + @override + String get regionScopeCleared => 'Region scope cleared'; + + @override + String get selectDestinationFirst => + 'Select a channel, contact, or room first'; + + @override + String get recipientNoLongerAvailable => 'Recipient is no longer available'; + + @override + String failedToSendMessage(String error) { + return 'Failed to send: $error'; + } + + @override + String get tictactoeDirectOnly => + 'Tic-Tac-Toe works only in direct messages. Choose a contact first.'; + + @override + String get deviceKeyUnavailable => 'Device key is unavailable'; + + @override + String get imageCompressionFailed => 'Image compression failed'; + + @override + String get imageFragmentationFailed => 'Image fragmentation failed'; + + @override + String get failedToAnnounceImage => 'Failed to announce image'; + + @override + String get noRecipientSelected => 'No recipient selected'; + + @override + String get imageSendFailed => 'Image send failed'; + + @override + String get microphonePermissionRequiredForVoice => + 'Microphone permission required for voice'; + + @override + String get recordingDiscarded => 'Recording discarded'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'You are about to send $mediaType to the Public Channel. This is not advised because everyone on the mesh may receive it. Choose a private or tagged channel unless this is what you want.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Select a private channel to share your location.'; + + @override + String get stopSharingMyLocation => 'Stop sharing my location'; + + @override + String get shareMyLocation => 'Share my location'; + + @override + String get stopRecording => 'Stop recording'; + + @override + String get recordVoice => 'Record voice'; + + @override + String get moreActions => 'More actions'; + + @override + String get noMessagesInFilter => 'No messages in this filter'; + + @override + String get noMatchesInFilter => 'No matches in this filter'; + + @override + String syncFailed(String error) { + return 'Sync failed: $error'; + } + + @override + String get invalidLink => 'Invalid link'; + + @override + String get cannotOpenLink => 'Cannot open link'; + + @override + String get failedToOpenLink => 'Failed to open link'; + + @override + String get failedToResendMessage => 'Failed to resend message'; + + @override + String retryFailed(String error) { + return 'Retry failed: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Delete drawing & message'; + + @override + String get lastEchoRelay => 'Last echo relay'; + + @override + String get lastEchoPath => 'Last echo path'; + + @override + String get lastEchoBytesReport => 'Last echo bytes report'; + + @override + String get noGpsCoordinatesAvailable => 'No GPS coordinates available'; + + @override + String get notASarMarker => 'Not a SAR marker'; + + @override + String failedToSaveTemplate(String error) { + return 'Failed to save template: $error'; + } + + @override + String get drawingNotFound => 'Drawing not found'; + + @override + String get unknownDrawingType => 'Unknown drawing type'; + + @override + String get customMapMarker => 'Custom map marker'; + + @override + String customMapPointLabel(String point) { + return 'Point: $point'; + } + + @override + String mapIdLabel(String id) { + return 'Map ID: $id'; + } + + @override + String get cannotFetchVoice => 'Cannot fetch voice'; + + @override + String get senderContactUnknown => + 'Sender contact is unknown. Sync contacts first.'; + + @override + String get senderRouteUnknown => + 'Sender route is unknown. Sync contacts/path first.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Message is too far ($hops hops, max $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'Sender route did not respond to a path check. Sync contacts/path and try again.'; + + @override + String get senderRouteNoRawResponse => + 'Sender route did not respond on the raw transport path.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Voice fetch over $hops hops may take a while.'; + } + + @override + String get voiceReceiveCanceled => 'Voice receive canceled'; + + @override + String get fetchingMissingVoiceFragments => + 'Fetching missing voice fragments'; + + @override + String get receivingVoice => 'Receiving voice'; + + @override + String get cannotFetchImage => 'Cannot fetch image'; + + @override + String imageFetchOverHops(String hops) { + return 'Image fetch over $hops hops may take a while.'; + } + + @override + String get imageAlreadyBeingReceived => 'Image is already being received'; + + @override + String get loadImage => 'Load image'; + + @override + String get imageFetchFailedToSendRequest => + 'Image fetch failed to send request'; + + @override + String get imageUnavailable => 'Image unavailable right now'; + + @override + String get imageFetchTimedOut => 'Image fetch timed out'; + + @override + String get imageReceiveCanceled => 'Image receive canceled'; + + @override + String get closeImagePreview => 'Close image preview'; + + @override + String get recordingReleaseToSend => 'Recording... release to send voice'; + + @override + String get sendingVoice => 'Sending voice...'; + + @override + String get sendLongPressToRecordVoice => 'Send (long press to record voice)'; + + @override + String get discardRecording => 'Discard recording'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Broadcast lanes for nearby mesh traffic'; + + @override + String get roomsSectionDescription => + 'Shared spaces for ongoing team coordination'; + + @override + String get contactsSectionDescription => + 'Direct people and devices you can reach'; + + @override + String get noRecentChatters => 'No recent chatters'; + + @override + String get public => 'Public'; + + @override + String get failedToSendTicTacToeMove => 'Failed to send Tic-Tac-Toe move'; + + @override + String get youWon => 'You won'; + + @override + String get opponentWon => 'Opponent won'; + + @override + String get gameDraw => 'Draw'; + + @override + String get yourTurn => 'Your turn'; + + @override + String get opponentTurn => 'Opponent turn'; + + @override + String get pleaseEnterValidCoordinates => 'Please enter valid coordinates'; + + @override + String get locationNotAvailable => 'Location not available'; + + @override + String get customCaveMapPoint => 'Custom cave map point'; + + @override + String get mapPoint => 'Map point'; + + @override + String get fetchingMissingImageFragments => 'Fetching missing fragments…'; + + @override + String get loadingImage => 'Loading…'; + + @override + String get receivingImage => 'Receiving…'; + + @override + String get tapToLoad => 'Tap to load'; + + @override + String get connectDevice => 'Connect Device'; + + @override + String get chooseTransportSubtitle => + 'Choose Bluetooth, WiFi, or Serial transport'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Network'; + + @override + String get serial => 'Serial'; + + @override + String get scan => 'Scan'; + + @override + String get pressScanToSearchForDevices => + 'Press scan to search for nearby devices'; + + @override + String failedToConnectToDevice(String name) { + return 'Failed to connect to $name'; + } + + @override + String get unknownDevice => 'Unknown Device'; + + @override + String get showingCachedResultsTapRefresh => + 'Showing cached results. Tap refresh to rescan.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Scanning local network for MeshCore WiFi devices on port $port'; + } + + @override + String get cancelScanAndAddServer => 'Cancel scan and add server'; + + @override + String get addServer => 'Add server'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Scanning... $scanned/$total IPs'; + } + + @override + String get cancelAndEnterManually => 'Cancel and enter manually'; + + @override + String get noRecentOrDiscoveredServers => + 'No recent or discovered servers yet'; + + @override + String get recentlyUsed => 'Recently used'; + + @override + String get connecting => 'Connecting...'; + + @override + String get discoveredOnThisNetwork => 'Discovered on this network'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'Server at $host:$port is no longer available. Please scan again to find active servers.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Failed to connect to $host:$port'; + } + + @override + String get enterValidIpAddress => 'Enter a valid IP address'; + + @override + String get enterValidTcpPort => 'Enter a valid TCP port'; + + @override + String get ipAddress => 'IP address'; + + @override + String get tcpPort => 'TCP port'; + + @override + String get customServerPort => 'Custom server port'; + + @override + String get chat => 'Chat'; + + @override + String get restoringPreviousLink => 'Restoring previous link'; + + @override + String get noDeviceConnected => 'No device connected'; + + @override + String get sendAdvert => 'Send my contact'; + + @override + String get advertMode => 'Send my contact'; + + @override + String get advertModeSubtitle => + 'Choose how far your contact info should travel.'; + + @override + String get floodAdvertSent => 'Contact sent across the mesh'; + + @override + String get directAdvertSent => 'Contact sent to nearby devices'; +} diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart new file mode 100644 index 0000000..c21bc83 --- /dev/null +++ b/lib/l10n/app_localizations_es.dart @@ -0,0 +1,3461 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Spanish Castilian (`es`). +class AppLocalizationsEs extends AppLocalizations { + AppLocalizationsEs([String locale = 'es']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Mensajes'; + + @override + String get contacts => 'Contactos'; + + @override + String get map => 'Mapa'; + + @override + String get settings => 'Configuración'; + + @override + String get connect => 'Conectar'; + + @override + String get disconnect => 'Desconectar'; + + @override + String get noDevicesFound => 'No se encontraron dispositivos'; + + @override + String get scanAgain => 'Buscar de nuevo'; + + @override + String get deviceNotConnected => 'Dispositivo no conectado'; + + @override + String get locationPermissionDenied => 'Permiso de ubicación denegado'; + + @override + String get locationPermissionPermanentlyDenied => + 'Permiso de ubicación denegado permanentemente. Por favor, actívalo en Configuración.'; + + @override + String get locationPermissionRequired => + 'El permiso de ubicación es necesario para el seguimiento GPS y la coordinación del equipo. Puedes activarlo más tarde en Configuración.'; + + @override + String get locationServicesDisabled => + 'Los servicios de ubicación están desactivados. Por favor, actívalos en Configuración.'; + + @override + String get failedToGetGpsLocation => 'Error al obtener la ubicación GPS'; + + @override + String failedToAdvertise(String error) { + return 'Error al anunciar: $error'; + } + + @override + String get cancelReconnection => 'Cancelar reconexión'; + + @override + String get general => 'General'; + + @override + String get theme => 'Tema'; + + @override + String get chooseTheme => 'Elegir tema'; + + @override + String get light => 'Claro'; + + @override + String get dark => 'Oscuro'; + + @override + String get blueLightTheme => 'Tema azul claro'; + + @override + String get blueDarkTheme => 'Tema azul oscuro'; + + @override + String get sarRed => 'SAR Rojo'; + + @override + String get alertEmergencyMode => 'Modo de alerta/emergencia'; + + @override + String get sarGreen => 'SAR Verde'; + + @override + String get safeAllClearMode => 'Modo seguro/todo despejado'; + + @override + String get autoSystem => 'Auto (Sistema)'; + + @override + String get followSystemTheme => 'Seguir el tema del sistema'; + + @override + String get showRxTxIndicators => 'Mostrar indicadores RX/TX'; + + @override + String get disableMap => 'Desactivar mapa'; + + @override + String get language => 'Idioma'; + + @override + String get chooseLanguage => 'Elegir idioma'; + + @override + String get save => 'Guardar'; + + @override + String get cancel => 'Cancelar'; + + @override + String get close => 'Cerrar'; + + @override + String get about => 'Acerca de'; + + @override + String get appVersion => 'Versión de la aplicación'; + + @override + String get aboutMeshCoreSar => 'Acerca de MeshCore SAR'; + + @override + String get aboutDescription => + 'Una aplicación de Búsqueda y Rescate diseñada para equipos de respuesta a emergencias. Las características incluyen:\n\n• Red mesh BLE para comunicación entre dispositivos\n• Mapas sin conexión con múltiples opciones de capas\n• Seguimiento en tiempo real de miembros del equipo\n• Marcadores tácticos SAR (persona encontrada, fuego, zona de preparación)\n• Gestión de contactos y mensajería\n• Seguimiento GPS con rumbo de brújula\n• Caché de teselas de mapa para uso sin conexión'; + + @override + String get technologiesUsed => 'Tecnologías utilizadas:'; + + @override + String get technologiesList => + '• Flutter para desarrollo multiplataforma\n• BLE (Bluetooth Low Energy) para redes mesh\n• OpenStreetMap para mapas\n• Provider para gestión de estado\n• SharedPreferences para almacenamiento local'; + + @override + String get moreInfo => 'Más información'; + + @override + String get packageName => 'Nombre del paquete'; + + @override + String get sampleData => 'Datos de muestra'; + + @override + String get sampleDataDescription => + 'Cargar o borrar contactos de muestra, mensajes de canal y marcadores SAR para pruebas'; + + @override + String get loadSampleData => 'Cargar datos de muestra'; + + @override + String get clearAllData => 'Borrar todos los datos'; + + @override + String get clearAllDataConfirmTitle => 'Borrar todos los datos'; + + @override + String get clearAllDataConfirmMessage => + 'Esto borrará todos los contactos y marcadores SAR. ¿Estás seguro?'; + + @override + String get clear => 'Borrar'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Cargados $teamCount miembros del equipo, $channelCount canales, $sarCount marcadores SAR, $messageCount mensajes'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Error al cargar datos de muestra: $error'; + } + + @override + String get allDataCleared => 'Todos los datos borrados'; + + @override + String get failedToStartBackgroundTracking => + 'Error al iniciar el seguimiento en segundo plano. Verifica los permisos y la conexión BLE.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Difusión de ubicación: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'El PIN predeterminado para dispositivos sin pantalla es 123456. ¿Problemas para emparejar? Olvida el dispositivo bluetooth en la configuración del sistema.'; + + @override + String get noMessagesYet => 'Aún no hay mensajes'; + + @override + String get pullDownToSync => 'Desliza hacia abajo para sincronizar mensajes'; + + @override + String get deleteContact => 'Eliminar contacto'; + + @override + String get delete => 'Eliminar'; + + @override + String get viewOnMap => 'Ver en el mapa'; + + @override + String get refresh => 'Actualizar'; + + @override + String get resetPath => 'Restablecer ruta (Re-enrutar)'; + + @override + String copiedToClipboard(String label) { + return '$label copiado al portapapeles'; + } + + @override + String get pleaseEnterPassword => 'Por favor, introduce una contraseña'; + + @override + String failedToSyncContacts(String error) { + return 'Error al sincronizar contactos: $error'; + } + + @override + String get loggedInSuccessfully => + '¡Inicio de sesión exitoso! Esperando mensajes de la sala...'; + + @override + String get loginFailed => 'Error de inicio de sesión - contraseña incorrecta'; + + @override + String loggingIn(String roomName) { + return 'Iniciando sesión en $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Error al enviar inicio de sesión: $error'; + } + + @override + String get lowLocationAccuracy => 'Baja precisión de ubicación'; + + @override + String get continue_ => 'Continuar'; + + @override + String get sendSarMarker => 'Enviar marcador SAR'; + + @override + String get deleteDrawing => 'Eliminar dibujo'; + + @override + String get drawingTools => 'Herramientas de Dibujo'; + + @override + String get drawLine => 'Dibujar línea'; + + @override + String get drawLineDesc => 'Dibujar una línea a mano alzada en el mapa'; + + @override + String get drawRectangle => 'Dibujar rectángulo'; + + @override + String get drawRectangleDesc => 'Dibujar un área rectangular en el mapa'; + + @override + String get measureDistance => 'Medir distancia'; + + @override + String get measureDistanceDesc => + 'Presión prolongada en dos puntos para medir'; + + @override + String get clearMeasurement => 'Borrar medición'; + + @override + String distanceLabel(String distance) { + return 'Distancia: $distance'; + } + + @override + String get longPressForSecondPoint => + 'Presión prolongada para el segundo punto'; + + @override + String get longPressToStartMeasurement => + 'Presión prolongada para establecer el primer punto'; + + @override + String get longPressToStartNewMeasurement => + 'Presión prolongada para nueva medición'; + + @override + String get shareDrawings => 'Compartir dibujos'; + + @override + String get clearAllDrawings => 'Borrar todos los dibujos'; + + @override + String get completeLine => 'Completar línea'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Transmitir $count dibujo$plural al equipo'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Eliminar todos los $count dibujo$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return '¿Eliminar todos los $count dibujo$plural del mapa?'; + } + + @override + String get drawing => 'Dibujo'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Compartir $count dibujo$plural'; + } + + @override + String get showReceivedDrawings => 'Mostrar dibujos recibidos'; + + @override + String get showingAllDrawings => 'Mostrando todos los dibujos'; + + @override + String get showingOnlyYourDrawings => 'Mostrando solo tus dibujos'; + + @override + String get showSarMarkers => 'Mostrar marcadores SAR'; + + @override + String get showingSarMarkers => 'Mostrando marcadores SAR'; + + @override + String get hidingSarMarkers => 'Ocultando marcadores SAR'; + + @override + String get clearAll => 'Borrar todo'; + + @override + String get publicChannel => 'Canal público'; + + @override + String get broadcastToAll => 'Difundir a todos los nodos cercanos (efímero)'; + + @override + String get storedPermanently => 'Almacenado permanentemente en la sala'; + + @override + String get notConnectedToDevice => 'No conectado al dispositivo'; + + @override + String get typeYourMessage => 'Escribe tu mensaje...'; + + @override + String get quickLocationMarker => 'Marcador de ubicación rápida'; + + @override + String get markerType => 'Tipo de marcador'; + + @override + String get sendTo => 'Enviar a'; + + @override + String get noDestinationsAvailable => 'No hay destinos disponibles.'; + + @override + String get selectDestination => 'Seleccionar destino...'; + + @override + String get ephemeralBroadcastInfo => + 'Efímero: Solo difusión por el aire. No se almacena - los nodos deben estar en línea.'; + + @override + String get persistentRoomInfo => + 'Persistente: Almacenado de manera inmutable en la sala. Se sincroniza automáticamente y se conserva sin conexión.'; + + @override + String get location => 'Ubicación'; + + @override + String get fromMap => 'Desde el mapa'; + + @override + String get gettingLocation => 'Obteniendo ubicación...'; + + @override + String get locationError => 'Error de ubicación'; + + @override + String get retry => 'Reintentar'; + + @override + String get refreshLocation => 'Actualizar ubicación'; + + @override + String accuracyMeters(int accuracy) { + return 'Precisión: ±${accuracy}m'; + } + + @override + String get notesOptional => 'Notas (opcional)'; + + @override + String get addAdditionalInformation => 'Agregar información adicional...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'La precisión de la ubicación es ±${accuracy}m. Esto puede no ser lo suficientemente preciso para operaciones SAR.\n\n¿Continuar de todos modos?'; + } + + @override + String get loginToRoom => 'Iniciar sesión en la sala'; + + @override + String get enterPasswordInfo => + 'Introduce la contraseña para acceder a esta sala. La contraseña se guardará para uso futuro.'; + + @override + String get password => 'Contraseña'; + + @override + String get enterRoomPassword => 'Introduce la contraseña de la sala'; + + @override + String get loggingInDots => 'Iniciando sesión...'; + + @override + String get login => 'Iniciar sesión'; + + @override + String failedToAddRoom(String error) { + return 'Error al agregar la sala al dispositivo: $error\n\nLa sala puede no haber anunciado aún.\nIntenta esperar a que la sala transmita.'; + } + + @override + String get direct => 'Directo'; + + @override + String get flood => 'Inundación'; + + @override + String get autoSend => 'Envío automático'; + + @override + String get autoSendDescription => 'Enviar por la ruta actual.'; + + @override + String get sendDirect => 'Envío directo'; + + @override + String get sendDirectDescription => 'Enviar directamente a este contacto.'; + + @override + String get sendFlood => 'Envío por inundación'; + + @override + String get sendFloodDescription => + 'Enviar a través de todos los repetidores.'; + + @override + String get loggedIn => 'Sesión iniciada'; + + @override + String get noGpsData => 'Sin datos GPS'; + + @override + String get distance => 'Distancia'; + + @override + String directPingTimeout(String name) { + return 'Tiempo de espera de ping directo agotado - reintentando $name con inundación...'; + } + + @override + String pingFailed(String name) { + return 'Ping fallido a $name - no se recibió respuesta'; + } + + @override + String deleteContactConfirmation(String name) { + return '¿Estás seguro de que quieres eliminar \"$name\"?\n\nEsto eliminará el contacto tanto de la aplicación como del dispositivo de radio compañero.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Error al eliminar contacto: $error'; + } + + @override + String get type => 'Tipo'; + + @override + String get publicKey => 'Clave pública'; + + @override + String get lastSeen => 'Visto por última vez'; + + @override + String get yes => 'Sí'; + + @override + String get no => 'No'; + + @override + String get permissions => 'Permisos'; + + @override + String get telemetry => 'Telemetría'; + + @override + String get voltage => 'Voltaje'; + + @override + String get battery => 'Batería'; + + @override + String get temperature => 'Temperatura'; + + @override + String get humidity => 'Humedad'; + + @override + String get pressure => 'Presión'; + + @override + String get gpsTelemetry => 'GPS (Telemetría)'; + + @override + String get reLoginToRoom => 'Re-iniciar sesión en la sala'; + + @override + String get heading => 'Rumbo'; + + @override + String get elevation => 'Elevación'; + + @override + String get accuracy => 'Precisión'; + + @override + String get bearing => 'Rumbo'; + + @override + String get direction => 'Dirección'; + + @override + String get filterMarkers => 'Filtrar marcadores'; + + @override + String get filterMarkersTooltip => 'Filtrar marcadores'; + + @override + String get contactsFilter => 'Contactos'; + + @override + String get repeatersFilter => 'Repetidores'; + + @override + String get sarMarkers => 'Marcadores SAR'; + + @override + String get foundPerson => 'Persona encontrada'; + + @override + String get fire => 'Fuego'; + + @override + String get stagingArea => 'Área de preparación'; + + @override + String get showAll => 'Mostrar todo'; + + @override + String get locationUnavailable => 'Ubicación no disponible'; + + @override + String get ahead => 'adelante'; + + @override + String degreesRight(int degrees) { + return '$degrees° derecha'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° izquierda'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Lat: $latitude Lon: $longitude'; + } + + @override + String get noContactsYet => 'Aún no hay contactos'; + + @override + String get connectToDeviceToLoadContacts => + 'Conéctate a un dispositivo para cargar contactos'; + + @override + String get teamMembers => 'Miembros del equipo'; + + @override + String get repeaters => 'Repetidores'; + + @override + String get rooms => 'Salas'; + + @override + String get channels => 'Canales'; + + @override + String get selectMapLayer => 'Seleccionar capa de mapa'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'ESRI Satélite'; + + @override + String get googleHybrid => 'Google Híbrido'; + + @override + String get googleRoadmap => 'Google Mapa de Carreteras'; + + @override + String get googleTerrain => 'Google Terreno'; + + @override + String get dragToPosition => 'Arrastrar a posición'; + + @override + String get createSarMarker => 'Crear marcador SAR'; + + @override + String get compass => 'Brújula'; + + @override + String get navigationAndContacts => 'Navegación y contactos'; + + @override + String get sarAlert => 'ALERTA SAR'; + + @override + String get textCopiedToClipboard => 'Texto copiado al portapapeles'; + + @override + String get cannotReplySenderMissing => + 'No se puede responder: falta información del remitente'; + + @override + String get cannotReplyContactNotFound => + 'No se puede responder: contacto no encontrado'; + + @override + String get copyText => 'Copiar texto'; + + @override + String get saveAsTemplate => 'Guardar como Plantilla'; + + @override + String get templateSaved => 'Plantilla guardada exitosamente'; + + @override + String get templateAlreadyExists => 'Ya existe una plantilla con este emoji'; + + @override + String get deleteMessage => 'Eliminar mensaje'; + + @override + String get deleteMessageConfirmation => + '¿Está seguro de que desea eliminar este mensaje?'; + + @override + String get shareLocation => 'Compartir ubicación'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nCoordenadas: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'Ubicación SAR'; + + @override + String get justNow => 'Justo ahora'; + + @override + String minutesAgo(int minutes) { + return 'Hace ${minutes}m'; + } + + @override + String hoursAgo(int hours) { + return 'Hace ${hours}h'; + } + + @override + String daysAgo(int days) { + return 'Hace ${days}d'; + } + + @override + String secondsAgo(int seconds) { + return 'Hace ${seconds}s'; + } + + @override + String get sending => 'Enviando...'; + + @override + String get sent => 'Enviado'; + + @override + String get delivered => 'Entregado'; + + @override + String deliveredWithTime(int time) { + return 'Entregado (${time}ms)'; + } + + @override + String get failed => 'Fallido'; + + @override + String get broadcast => 'Difusión'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Entregado a $delivered/$total contactos'; + } + + @override + String get allDelivered => 'Todo entregado'; + + @override + String get recipientDetails => 'Detalles de destinatarios'; + + @override + String get pending => 'Pendiente'; + + @override + String get sarMarkerFoundPerson => 'Persona encontrada'; + + @override + String get sarMarkerFire => 'Ubicación de fuego'; + + @override + String get sarMarkerStagingArea => 'Área de preparación'; + + @override + String get sarMarkerObject => 'Objeto encontrado'; + + @override + String get from => 'De'; + + @override + String get coordinates => 'Coordenadas'; + + @override + String get tapToViewOnMap => 'Toca para ver en el mapa'; + + @override + String get radioSettings => 'Configuración de radio'; + + @override + String get frequencyMHz => 'Frecuencia (MHz)'; + + @override + String get bandwidth => 'Ancho de banda'; + + @override + String get spreadingFactor => 'Factor de dispersión'; + + @override + String get codingRate => 'Tasa de codificación'; + + @override + String get txPowerDbm => 'Potencia TX (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Máx: $power dBm'; + } + + @override + String get you => 'Tú'; + + @override + String exportFailed(String error) { + return 'Error al exportar: $error'; + } + + @override + String importFailed(String error) { + return 'Error al importar: $error'; + } + + @override + String get unknown => 'Desconocido'; + + @override + String get onlineLayers => 'Capas en línea'; + + @override + String get locationTrail => 'Rastro de ubicación'; + + @override + String get showTrailOnMap => 'Mostrar rastro en el mapa'; + + @override + String get trailVisible => 'El rastro es visible en el mapa'; + + @override + String get trailHiddenRecording => 'El rastro está oculto (aún grabando)'; + + @override + String get duration => 'Duración'; + + @override + String get points => 'Puntos'; + + @override + String get clearTrail => 'Borrar rastro'; + + @override + String get clearTrailQuestion => '¿Borrar rastro?'; + + @override + String get clearTrailConfirmation => + '¿Estás seguro de que quieres borrar el rastro de ubicación actual? Esta acción no se puede deshacer.'; + + @override + String get noTrailRecorded => 'Aún no se ha grabado rastro'; + + @override + String get startTrackingToRecord => + 'Inicia el seguimiento de ubicación para grabar tu rastro'; + + @override + String get trailControls => 'Controles del rastro'; + + @override + String get model => 'Modelo'; + + @override + String get version => 'Versión'; + + @override + String get maxContacts => 'Contactos máximos'; + + @override + String get maxChannels => 'Canales máximos'; + + @override + String get publicInfo => 'Información pública'; + + @override + String get useCurrentLocation => 'Usar ubicación actual'; + + @override + String get noneUnknown => 'Ninguno/Desconocido'; + + @override + String get chatNode => 'Nodo de chat'; + + @override + String get repeater => 'Repetidor'; + + @override + String get roomChannel => 'Sala/Canal'; + + @override + String typeNumber(int number) { + return 'Tipo $number'; + } + + @override + String failedToSave(String error) { + return 'Error al guardar: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Error al obtener ubicación: $error'; + } + + @override + String get sarTemplates => 'Plantillas SAR'; + + @override + String get addTemplate => 'Agregar plantilla'; + + @override + String get editTemplate => 'Editar plantilla'; + + @override + String get deleteTemplate => 'Eliminar plantilla'; + + @override + String get templateName => 'Nombre de plantilla'; + + @override + String get templateNameHint => 'p. ej., Persona encontrada'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Se requiere emoji'; + + @override + String get nameRequired => 'Se requiere nombre'; + + @override + String get templateDescription => 'Descripcion (opcional)'; + + @override + String get templateDescriptionHint => 'Anade contexto adicional...'; + + @override + String get templateColor => 'Color'; + + @override + String get previewFormat => 'Vista previa (formato de mensaje SAR)'; + + @override + String get importFromClipboard => 'Importar'; + + @override + String get exportToClipboard => 'Exportar'; + + @override + String deleteTemplateConfirmation(String name) { + return '¿Eliminar la plantilla \'$name\'?'; + } + + @override + String get templateAdded => 'Plantilla anadida'; + + @override + String get templateUpdated => 'Plantilla actualizada'; + + @override + String get templateDeleted => 'Plantilla eliminada'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Se importaron $count plantillas', + one: 'Se importo 1 plantilla', + zero: 'No se importaron plantillas', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Se exportaron $count plantillas al portapapeles', + one: 'Se exporto 1 plantilla al portapapeles', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Restablecer valores predeterminados'; + + @override + String get resetToDefaultsConfirmation => + 'Esto eliminará todas las plantillas personalizadas y restaurará las 4 plantillas predeterminadas. ¿Continuar?'; + + @override + String get reset => 'Restablecer'; + + @override + String get resetComplete => + 'Plantillas restablecidas a valores predeterminados'; + + @override + String get noTemplates => 'No hay plantillas disponibles'; + + @override + String get tapAddToCreate => 'Toca + para crear tu primera plantilla'; + + @override + String get ok => 'OK'; + + @override + String get locationPermission => 'Permiso de ubicación'; + + @override + String get checking => 'Comprobando...'; + + @override + String get locationPermissionGrantedAlways => 'Concedido (Siempre)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Concedido (Durante el uso)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Denegado - Toca para solicitar'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Denegado permanentemente - Abrir ajustes'; + + @override + String get locationPermissionDialogContent => + 'El permiso de ubicación está permanentemente denegado. Por favor, actívalo en la configuración de tu dispositivo para usar el rastreo GPS y compartir ubicación.'; + + @override + String get openSettings => 'Abrir ajustes'; + + @override + String get locationPermissionGranted => '¡Permiso de ubicación concedido!'; + + @override + String get locationPermissionRequiredForGps => + 'El permiso de ubicación es necesario para el rastreo GPS y compartir ubicación.'; + + @override + String get locationPermissionAlreadyGranted => + 'El permiso de ubicación ya está concedido.'; + + @override + String get sarNavyBlue => 'SAR Azul Marino'; + + @override + String get sarNavyBlueDescription => 'Modo Profesional/Operaciones'; + + @override + String get selectRecipient => 'Seleccionar destinatario'; + + @override + String get broadcastToAllNearby => 'Transmitir a todos cercanos'; + + @override + String get searchRecipients => 'Buscar destinatarios...'; + + @override + String get noContactsFound => 'No se encontraron contactos'; + + @override + String get noRoomsFound => 'No se encontraron salas'; + + @override + String get noRecipientsAvailable => 'No hay destinatarios disponibles'; + + @override + String get noChannelsFound => 'No se encontraron canales'; + + @override + String get newMessage => 'Nuevo mensaje'; + + @override + String get channel => 'Canal'; + + @override + String get samplePoliceLead => 'Jefe de Policía'; + + @override + String get sampleDroneOperator => 'Operador de Dron'; + + @override + String get sampleFirefighterAlpha => 'Bombero'; + + @override + String get sampleMedicCharlie => 'Médico'; + + @override + String get sampleCommandDelta => 'Comando'; + + @override + String get sampleFireEngine => 'Camión de Bomberos'; + + @override + String get sampleAirSupport => 'Apoyo Aéreo'; + + @override + String get sampleBaseCoordinator => 'Coordinador de Base'; + + @override + String get channelEmergency => 'Emergencia'; + + @override + String get channelCoordination => 'Coordinación'; + + @override + String get channelUpdates => 'Actualizaciones'; + + @override + String get sampleTeamMember => 'Miembro de Equipo de Muestra'; + + @override + String get sampleScout => 'Explorador de Muestra'; + + @override + String get sampleBase => 'Base de Muestra'; + + @override + String get sampleSearcher => 'Buscador de Muestra'; + + @override + String get sampleObjectBackpack => ' Mochila encontrada - color azul'; + + @override + String get sampleObjectVehicle => + ' Vehículo abandonado - verificar propietario'; + + @override + String get sampleObjectCamping => ' Equipo de camping descubierto'; + + @override + String get sampleObjectTrailMarker => + ' Marcador de sendero encontrado fuera del camino'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Todos los equipos reporten'; + + @override + String get sampleMsgWeatherUpdate => + 'Actualización del clima: Cielo despejado, temp 18°C'; + + @override + String get sampleMsgBaseCamp => + 'Campamento base establecido en área de preparación'; + + @override + String get sampleMsgTeamAlpha => 'Equipo moviéndose al sector 2'; + + @override + String get sampleMsgRadioCheck => + 'Prueba de radio - todas las estaciones respondan'; + + @override + String get sampleMsgWaterSupply => + 'Suministro de agua disponible en punto de control 3'; + + @override + String get sampleMsgTeamBravo => 'Equipo reportando: sector 1 despejado'; + + @override + String get sampleMsgEtaRallyPoint => 'ETA al punto de encuentro: 15 minutos'; + + @override + String get sampleMsgSupplyDrop => + 'Caída de suministros confirmada para las 14:00'; + + @override + String get sampleMsgDroneSurvey => + 'Inspección con dron completada - sin hallazgos'; + + @override + String get sampleMsgTeamCharlie => 'Equipo solicitando apoyo'; + + @override + String get sampleMsgRadioDiscipline => + 'Todas las unidades: mantener disciplina de radio'; + + @override + String get sampleMsgUrgentMedical => + 'URGENTE: Asistencia médica necesaria en sector 4'; + + @override + String get sampleMsgAdultMale => ' Hombre adulto, consciente'; + + @override + String get sampleMsgFireSpotted => 'Fuego avistado - coordenadas próximas'; + + @override + String get sampleMsgSpreadingRapidly => ' ¡Se propaga rápidamente!'; + + @override + String get sampleMsgPriorityHelicopter => + 'PRIORIDAD: Necesitamos apoyo de helicóptero'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Equipo médico en camino a su ubicación'; + + @override + String get sampleMsgEvacHelicopter => + 'Helicóptero de evacuación ETA 10 minutos'; + + @override + String get sampleMsgEmergencyResolved => + 'Emergencia resuelta - todo despejado'; + + @override + String get sampleMsgEmergencyStagingArea => + ' Área de preparación de emergencia'; + + @override + String get sampleMsgEmergencyServices => + 'Servicios de emergencia notificados y respondiendo'; + + @override + String get sampleAlphaTeamLead => 'Líder de Equipo'; + + @override + String get sampleBravoScout => 'Explorador'; + + @override + String get sampleCharlieMedic => 'Médico'; + + @override + String get sampleDeltaNavigator => 'Navegador'; + + @override + String get sampleEchoSupport => 'Apoyo'; + + @override + String get sampleBaseCommand => 'Comando de Base'; + + @override + String get sampleFieldCoordinator => 'Coordinador de Campo'; + + @override + String get sampleMedicalTeam => 'Equipo Médico'; + + @override + String get mapDrawing => 'Dibujo del Mapa'; + + @override + String get navigateToDrawing => 'Navegar al Dibujo'; + + @override + String get copyCoordinates => 'Copiar Coordenadas'; + + @override + String get hideFromMap => 'Ocultar del Mapa'; + + @override + String get lineDrawing => 'Línea'; + + @override + String get rectangleDrawing => 'Rectángulo'; + + @override + String get manualCoordinates => 'Coordenadas Manuales'; + + @override + String get enterCoordinatesManually => 'Introducir coordenadas manualmente'; + + @override + String get latitudeLabel => 'Latitud'; + + @override + String get longitudeLabel => 'Longitud'; + + @override + String get exampleCoordinates => 'Ejemplo: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Compartir Dibujo'; + + @override + String get shareWithAllNearbyDevices => + 'Compartir con todos los dispositivos cercanos'; + + @override + String get shareToRoom => 'Compartir en Sala'; + + @override + String get sendToPersistentStorage => + 'Enviar a almacenamiento persistente de sala'; + + @override + String get deleteDrawingConfirm => + '¿Está seguro de que desea eliminar este dibujo?'; + + @override + String get drawingDeleted => 'Dibujo eliminado'; + + @override + String yourDrawingsCount(int count) { + return 'Sus Dibujos ($count)'; + } + + @override + String get shared => 'Compartido'; + + @override + String get line => 'Línea'; + + @override + String get rectangle => 'Rectángulo'; + + @override + String get updateAvailable => 'Actualización Disponible'; + + @override + String get currentVersion => 'Actual'; + + @override + String get latestVersion => 'Última'; + + @override + String get downloadUpdate => 'Descargar'; + + @override + String get updateLater => 'Más Tarde'; + + @override + String get cadastralParcels => 'Parcelas Catastrales'; + + @override + String get forestRoads => 'Caminos Forestales'; + + @override + String get wmsOverlays => 'Superposiciones WMS'; + + @override + String get hikingTrails => 'Senderos de Montaña'; + + @override + String get mainRoads => 'Carreteras Principales'; + + @override + String get houseNumbers => 'Números de Casa'; + + @override + String get fireHazardZones => 'Zonas de Riesgo de Incendio'; + + @override + String get historicalFires => 'Incendios Históricos'; + + @override + String get firebreaks => 'Cortafuegos'; + + @override + String get krasFireZones => 'Zonas de Incendio Kras'; + + @override + String get placeNames => 'Nombres de Lugares'; + + @override + String get municipalityBorders => 'Límites Municipales'; + + @override + String get topographicMap => 'Mapa Topográfico 1:25000'; + + @override + String get recentMessages => 'Mensajes Recientes'; + + @override + String get addChannel => 'Agregar Canal'; + + @override + String get channelName => 'Nombre del Canal'; + + @override + String get channelNameHint => 'ej. Equipo de Rescate Alfa'; + + @override + String get channelSecret => 'Contraseña del Canal'; + + @override + String get channelSecretHint => 'Contraseña compartida para este canal'; + + @override + String get channelSecretHelp => + 'Esta contraseña debe compartirse con todos los miembros del equipo que necesiten acceso a este canal'; + + @override + String get channelTypesInfo => + 'Canales hash (#equipo): Contraseña generada automáticamente del nombre. Mismo nombre = mismo canal en todos los dispositivos.\n\nCanales privados: Use contraseña explícita. Solo aquellos con la contraseña pueden unirse.'; + + @override + String get hashChannelInfo => + 'Canal hash: La contraseña se generará automáticamente del nombre del canal. Cualquiera que use el mismo nombre se unirá al mismo canal.'; + + @override + String get channelNameRequired => 'El nombre del canal es obligatorio'; + + @override + String get channelNameTooLong => + 'El nombre del canal debe tener 31 caracteres o menos'; + + @override + String get channelSecretRequired => 'La contraseña del canal es obligatoria'; + + @override + String get channelSecretTooLong => + 'La contraseña del canal debe tener 32 caracteres o menos'; + + @override + String get invalidAsciiCharacters => 'Solo se permiten caracteres ASCII'; + + @override + String get channelCreatedSuccessfully => 'Canal creado exitosamente'; + + @override + String channelCreationFailed(String error) { + return 'Error al crear el canal: $error'; + } + + @override + String get deleteChannel => 'Eliminar Canal'; + + @override + String deleteChannelConfirmation(String channelName) { + return '¿Está seguro de que desea eliminar el canal \"$channelName\"? Esta acción no se puede deshacer.'; + } + + @override + String get channelDeletedSuccessfully => 'Canal eliminado exitosamente'; + + @override + String channelDeletionFailed(String error) { + return 'Error al eliminar el canal: $error'; + } + + @override + String get createChannel => 'Crear Canal'; + + @override + String get wizardBack => 'Atrás'; + + @override + String get wizardSkip => 'Omitir'; + + @override + String get wizardNext => 'Siguiente'; + + @override + String get wizardGetStarted => 'Comenzar'; + + @override + String get wizardWelcomeTitle => 'Bienvenido a MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Ver tutorial de bienvenida'; + + @override + String get allTeamContacts => 'Todos los contactos del equipo'; + + @override + String directMessagesInfo(int count) { + return 'Mensajes directos con confirmaciones. Enviado a $count miembros del equipo.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'Marcador SAR enviado a $count contactos'; + } + + @override + String get noContactsAvailable => 'No hay contactos del equipo disponibles'; + + @override + String get reply => 'Responder'; + + @override + String get technicalDetails => 'Detalles técnicos'; + + @override + String get messageTechnicalDetails => 'Detalles técnicos del mensaje'; + + @override + String get linkQuality => 'Calidad del enlace'; + + @override + String get delivery => 'Entrega'; + + @override + String get status => 'Estado'; + + @override + String get expectedAckTag => 'Etiqueta ACK esperada'; + + @override + String get roundTrip => 'Ida y vuelta'; + + @override + String get retryAttempt => 'Intento de reenvío'; + + @override + String get floodFallback => 'Reserva de inundación'; + + @override + String get identity => 'Identidad'; + + @override + String get messageId => 'ID de mensaje'; + + @override + String get sender => 'Remitente'; + + @override + String get senderKey => 'Clave del remitente'; + + @override + String get recipient => 'Destinatario'; + + @override + String get recipientKey => 'Clave del destinatario'; + + @override + String get voice => 'Voz'; + + @override + String get voiceId => 'ID de voz'; + + @override + String get envelope => 'Envolvente'; + + @override + String get sessionProgress => 'Progreso de sesión'; + + @override + String get complete => 'Completo'; + + @override + String get rawDump => 'Volcado sin procesar'; + + @override + String get cannotRetryMissingRecipient => + 'No se puede reintentar: falta información del destinatario'; + + @override + String get voiceUnavailable => 'Voz no disponible en este momento'; + + @override + String get requestingVoice => 'Solicitando voz'; + + @override + String get device => 'dispositivo'; + + @override + String get change => 'Cambiar'; + + @override + String get wizardOverviewDescription => + 'Esta aplicación reúne mensajería MeshCore, actualizaciones SAR de campo, mapas y herramientas del dispositivo en un solo lugar.'; + + @override + String get wizardOverviewFeature1 => + 'Envía mensajes directos, publicaciones en salas y mensajes de canal desde la pestaña principal Mensajes.'; + + @override + String get wizardOverviewFeature2 => + 'Comparte marcadores SAR, dibujos del mapa, clips de voz e imágenes a través de la malla.'; + + @override + String get wizardOverviewFeature3 => + 'Conéctate por BLE o TCP y luego gestiona la radio complementaria desde la aplicación.'; + + @override + String get wizardMessagingTitle => 'Mensajería e informes de campo'; + + @override + String get wizardMessagingDescription => + 'Aquí los mensajes son más que texto plano. La aplicación ya admite varias cargas operativas y flujos de transferencia.'; + + @override + String get wizardMessagingFeature1 => + 'Envía mensajes directos, publicaciones en salas y tráfico de canal desde un solo editor.'; + + @override + String get wizardMessagingFeature2 => + 'Crea actualizaciones SAR y plantillas SAR reutilizables para informes de campo habituales.'; + + @override + String get wizardMessagingFeature3 => + 'Transfiere sesiones de voz e imágenes con progreso y estimaciones de tiempo de aire en la interfaz.'; + + @override + String get wizardConnectDeviceTitle => 'Conectar dispositivo'; + + @override + String get wizardConnectDeviceDescription => + 'Conecta tu radio MeshCore, elige un nombre y aplica un ajuste predefinido antes de continuar.'; + + @override + String get wizardSetupBadge => 'Configuración'; + + @override + String get wizardOverviewBadge => 'Resumen'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Conectado a $deviceName'; + } + + @override + String get wizardNoDeviceConnected => + 'Aún no hay ningún dispositivo conectado'; + + @override + String get wizardSkipForNow => 'Omitir por ahora'; + + @override + String get wizardDeviceNameLabel => 'Nombre del dispositivo'; + + @override + String get wizardDeviceNameHelp => + 'Este nombre se anuncia a otros usuarios de MeshCore.'; + + @override + String get wizardConfigRegionLabel => 'Región de configuración'; + + @override + String get wizardConfigRegionHelp => + 'Usa la lista oficial completa de ajustes predefinidos de MeshCore. El valor predeterminado es EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Asegúrate de que el ajuste seleccionado coincida con la normativa local de radio.'; + + @override + String get wizardPresetNote2 => + 'La lista coincide con la fuente oficial de ajustes predefinidos de la herramienta de configuración de MeshCore.'; + + @override + String get wizardPresetNote3 => + 'EU/UK (Narrow) sigue seleccionado por defecto durante la incorporación.'; + + @override + String get wizardSaving => 'Guardando...'; + + @override + String get wizardSaveAndContinue => 'Guardar y continuar'; + + @override + String get wizardEnterDeviceName => + 'Introduce un nombre de dispositivo antes de continuar.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return 'Se guardó $deviceName con $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Contactos, salas y repetidores'; + + @override + String get wizardNetworkDescription => + 'La pestaña Contactos organiza la red que descubres y las rutas que aprendes con el tiempo.'; + + @override + String get wizardNetworkFeature1 => + 'Revisa miembros del equipo, repetidores, salas, canales y anuncios pendientes en una sola lista.'; + + @override + String get wizardNetworkFeature2 => + 'Usa ping inteligente, acceso a salas, rutas aprendidas y herramientas de reinicio de ruta cuando la conectividad se complique.'; + + @override + String get wizardNetworkFeature3 => + 'Crea canales y gestiona destinos de red sin salir de la aplicación.'; + + @override + String get wizardMapOpsTitle => 'Mapa, rastros y geometría compartida'; + + @override + String get wizardMapOpsDescription => + 'El mapa de la aplicación está vinculado directamente a la mensajería, el seguimiento y las superposiciones SAR en lugar de ser un visor aparte.'; + + @override + String get wizardMapOpsFeature1 => + 'Sigue tu propia posición, las ubicaciones de tus compañeros y los rastros de movimiento en el mapa.'; + + @override + String get wizardMapOpsFeature2 => + 'Abre dibujos desde mensajes, prévisualos en línea y elimínalos del mapa cuando sea necesario.'; + + @override + String get wizardMapOpsFeature3 => + 'Usa vistas de mapa de repetidores y superposiciones compartidas para comprender el alcance de la red sobre el terreno.'; + + @override + String get wizardToolsTitle => 'Herramientas más allá de la mensajería'; + + @override + String get wizardToolsDescription => + 'Aquí hay más que las cuatro pestañas principales. La aplicación también incluye configuración, diagnóstico y flujos opcionales de sensores.'; + + @override + String get wizardToolsFeature1 => + 'Abre la configuración del dispositivo para cambiar ajustes de radio, telemetría, potencia TX y detalles del equipo complementario.'; + + @override + String get wizardToolsFeature2 => + 'Activa la pestaña Sensores cuando quieras paneles de sensores vigilados y acciones de actualización rápida.'; + + @override + String get wizardToolsFeature3 => + 'Usa registros de paquetes, escaneo de espectro y diagnósticos de desarrollador al solucionar problemas de la malla.'; + + @override + String get postConnectDiscoveryTitle => '¿Descubrir repetidores ahora?'; + + @override + String get postConnectDiscoveryDescription => + 'Ejecutar descubrimiento de repetidores justo después de conectarse para ver nodos MeshCore cercanos y agregarlos a su red más rápido.'; + + @override + String get contactInSensors => 'En Sensores'; + + @override + String get contactAddToSensors => 'Añadir a Sensores'; + + @override + String get contactSetPath => 'Definir ruta'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName se añadió a Sensores'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'No se pudo borrar la ruta: $error'; + } + + @override + String get contactRouteCleared => 'Ruta borrada'; + + @override + String contactRouteSet(String route) { + return 'Ruta establecida: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'No se pudo establecer la ruta: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'Tiempo de espera ACK'; + + @override + String get opcode => 'Código de operación'; + + @override + String get payload => 'Carga útil'; + + @override + String get hops => 'Saltos'; + + @override + String get hashSize => 'Tamaño del hash'; + + @override + String get pathBytes => 'Bytes de ruta'; + + @override + String get selectedPath => 'Ruta seleccionada'; + + @override + String get estimatedTx => 'Transmisión estimada'; + + @override + String get senderToReceipt => 'Del remitente al recibo'; + + @override + String get receivedCopies => 'Copias recibidas'; + + @override + String get retryCause => 'Causa del reintento'; + + @override + String get retryMode => 'Modo de reintento'; + + @override + String get retryResult => 'Resultado del reintento'; + + @override + String get lastRetry => 'Último reintento'; + + @override + String get rxPackets => 'Paquetes RX'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Velocidad'; + + @override + String get window => 'Ventana'; + + @override + String get posttxDelay => 'Retardo post-tx'; + + @override + String get bandpass => 'Paso de banda'; + + @override + String get bandpassFilterVoice => 'Filtro de paso de banda de voz'; + + @override + String get active => 'Activo'; + + @override + String get addContact => 'Añadir contacto'; + + @override + String get all => 'Todo'; + + @override + String get clearAllLabel => 'Borrar todo'; + + @override + String get clearFilters => 'Borrar filtros'; + + @override + String get clearRoute => 'Borrar ruta'; + + @override + String get clearMessages => 'Borrar mensajes'; + + @override + String get clearScale => 'Borrar escala'; + + @override + String get clearDiscoveries => 'Borrar descubrimientos'; + + @override + String get clearOnlineTraceDatabase => 'Borrar base de datos de trazas'; + + @override + String get clearAllChannels => 'Borrar todos los canales'; + + @override + String get clearAllContacts => 'Borrar todos los contactos'; + + @override + String get clearChannels => 'Borrar canales'; + + @override + String get clearContacts => 'Borrar contactos'; + + @override + String get clearPathOnMaxRetry => 'Borrar ruta en máximo reintento'; + + @override + String get create => 'Crear'; + + @override + String get custom => 'Personalizado'; + + @override + String get defaultValue => 'Predeterminado'; + + @override + String get duplicate => 'Duplicar'; + + @override + String get editName => 'Editar nombre'; + + @override + String get open => 'Abrir'; + + @override + String get paste => 'Pegar'; + + @override + String get preview => 'Vista previa'; + + @override + String get remove => 'Eliminar'; + + @override + String get rename => 'Renombrar'; + + @override + String get resolveAll => 'Resolver todo'; + + @override + String get send => 'Enviar'; + + @override + String get sendAnyway => 'Enviar de todos modos'; + + @override + String get share => 'Compartir'; + + @override + String get trace => 'Traza'; + + @override + String get discovery => 'Descubrimiento'; + + @override + String get discoverRepeaters => 'Descubrir repetidores'; + + @override + String get discoverSensors => 'Descubrir sensores'; + + @override + String get repeaterDiscoverySent => 'Descubrimiento de repetidores enviado'; + + @override + String get sensorDiscoverySent => 'Descubrimiento de sensores enviado'; + + @override + String get clearedPendingDiscoveries => + 'Descubrimientos pendientes borrados.'; + + @override + String get autoDiscovery => 'Descubrimiento automático'; + + @override + String get enableAutomaticAdding => 'Habilitar adición automática'; + + @override + String get autoaddRepeaters => 'Añadir repetidores automáticamente'; + + @override + String get autoaddRoomServers => 'Añadir servidores de sala automáticamente'; + + @override + String get autoaddSensors => 'Añadir sensores automáticamente'; + + @override + String get autoaddUsers => 'Añadir usuarios automáticamente'; + + @override + String get overwriteOldestWhenFull => + 'Sobrescribir más antiguos cuando esté lleno'; + + @override + String get storage => 'Almacenamiento'; + + @override + String get dangerZone => 'Zona de peligro'; + + @override + String get profiles => 'Perfiles'; + + @override + String get favourites => 'Favoritos'; + + @override + String get sensors => 'Sensores'; + + @override + String get others => 'Otros'; + + @override + String get gpsModule => 'Módulo GPS'; + + @override + String get liveTraffic => 'Tráfico en vivo'; + + @override + String get repeatersMap => 'Mapa de repetidores'; + + @override + String get blePacketLogs => 'Registros de paquetes BLE'; + + @override + String get onlineTraceDatabase => 'Base de datos de trazas'; + + @override + String get routePathByteSize => 'Tamaño en bytes de la ruta'; + + @override + String get messageNotifications => 'Notificaciones de mensajes'; + + @override + String get sarAlerts => 'Alertas SAR'; + + @override + String get discoveryNotifications => 'Notificaciones de descubrimiento'; + + @override + String get updateNotifications => 'Notificaciones de actualización'; + + @override + String get muteWhileAppIsOpen => 'Silenciar con la app abierta'; + + @override + String get disableContacts => 'Desactivar contactos'; + + @override + String get enableSensorsTab => 'Habilitar pestaña Sensores'; + + @override + String get enableProfiles => 'Habilitar perfiles'; + + @override + String get nearestRepeaterFallback => 'Repetidor más cercano como reserva'; + + @override + String get deleteAllStoredMessageHistory => + 'Eliminar todo el historial de mensajes'; + + @override + String get messageFontSize => 'Tamaño de fuente de mensajes'; + + @override + String get rotateMapWithHeading => 'Rotar mapa con rumbo'; + + @override + String get showMapDebugInfo => 'Mostrar info de depuración del mapa'; + + @override + String get openMapInFullscreen => 'Abrir mapa en pantalla completa'; + + @override + String get showSarMarkersLabel => 'Mostrar marcadores SAR'; + + @override + String get hideRepeatersOnMap => 'Ocultar repetidores en el mapa'; + + @override + String get setMapScale => 'Establecer escala del mapa'; + + @override + String get customMapScaleSaved => 'Escala de mapa personalizada guardada'; + + @override + String get voiceBitrate => 'Tasa de bits de voz'; + + @override + String get voiceCompressor => 'Compresor de voz'; + + @override + String get voiceLimiter => 'Limitador de voz'; + + @override + String get micAutoGain => 'Ganancia automática del micrófono'; + + @override + String get echoCancellation => 'Cancelación de eco'; + + @override + String get noiseSuppression => 'Supresión de ruido'; + + @override + String get trimSilenceInVoiceMessages => + 'Recortar silencio en mensajes de voz'; + + @override + String get compressor => 'Compresor'; + + @override + String get limiter => 'Limitador'; + + @override + String get autoGain => 'Ganancia automática'; + + @override + String get echoCancel => 'Eco'; + + @override + String get noiseSuppress => 'Ruido'; + + @override + String get silenceTrim => 'Silencio'; + + @override + String get maxImageSize => 'Tamaño máximo de imagen'; + + @override + String get imageCompression => 'Compresión de imagen'; + + @override + String get grayscale => 'Escala de grises'; + + @override + String get ultraMode => 'Modo ultra'; + + @override + String get fastPrivateGpsUpdates => 'Actualizaciones GPS privadas rápidas'; + + @override + String get movementThreshold => 'Umbral de movimiento'; + + @override + String get fastGpsMovementThreshold => 'Umbral de movimiento GPS rápido'; + + @override + String get fastGpsActiveuseInterval => 'Intervalo de uso activo GPS rápido'; + + @override + String get activeuseUpdateInterval => + 'Intervalo de actualización de uso activo'; + + @override + String get repeatNearbyTraffic => 'Repetir tráfico cercano'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Retransmitir a través de repetidores en la red'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Solo cercanos, sin inundación de repetidores'; + + @override + String get multihop => 'Multi-salto'; + + @override + String get createProfile => 'Crear perfil'; + + @override + String get renameProfile => 'Renombrar perfil'; + + @override + String get newProfile => 'Nuevo perfil'; + + @override + String get manageProfiles => 'Gestionar perfiles'; + + @override + String get enableProfilesToStartManagingThem => + 'Habilite los perfiles para comenzar a gestionarlos.'; + + @override + String get openMessage => 'Abrir mensaje'; + + @override + String get jumpToTheRelatedSarMessage => 'Ir al mensaje SAR relacionado'; + + @override + String get removeSarMarker => 'Eliminar marcador SAR'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Seleccione un destino para enviar el marcador SAR'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'Marcador SAR transmitido al canal público'; + + @override + String get sarMarkerSentToRoom => 'Marcador SAR enviado a la sala'; + + @override + String get loadFromGallery => 'Cargar desde galería'; + + @override + String get replaceImage => 'Reemplazar imagen'; + + @override + String get selectFromGallery => 'Seleccionar de galería'; + + @override + String get team => 'Equipo'; + + @override + String get found => 'Encontrado'; + + @override + String get staging => 'Área de reunión'; + + @override + String get object => 'Objeto'; + + @override + String get quiet => 'Silencioso'; + + @override + String get busy => 'Ocupado'; + + @override + String get searchMessages => 'Buscar mensajes'; + + @override + String get sendImageFromGallery => 'Enviar imagen desde galería'; + + @override + String get takePhoto => 'Tomar foto'; + + @override + String get allMessages => 'Todos los mensajes'; + + @override + String get sendToPublicChannel => '¿Enviar al canal público?'; + + @override + String get selectMarkerTypeAndDestination => + 'Seleccione tipo de marcador y destino'; + + @override + String get noDestinationsAvailableLabel => 'No hay destinos disponibles'; + + @override + String get image => 'Imagen'; + + @override + String get format => 'Formato'; + + @override + String get dimensions => 'Dimensiones'; + + @override + String get segments => 'Segmentos'; + + @override + String get transfers => 'Transferencias'; + + @override + String get downloadedBy => 'Descargado por'; + + @override + String get saveDiscoverySettings => 'Guardar ajustes de descubrimiento'; + + @override + String get savePublicInfo => 'Guardar información pública'; + + @override + String get saveRadioSettings => 'Guardar ajustes de radio'; + + @override + String get savePath => 'Guardar ruta'; + + @override + String get wipeDeviceData => 'Borrar datos del dispositivo'; + + @override + String get wipeDevice => 'Borrar dispositivo'; + + @override + String get destructiveDeviceActions => + 'Acciones destructivas del dispositivo.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Elija un preajuste o ajuste la configuración de radio.'; + + @override + String get used => 'Usado'; + + @override + String get total => 'Total'; + + @override + String get renameValue => 'Renombrar valor'; + + @override + String get customizeFields => 'Personalizar campos'; + + @override + String get livePreview => 'Vista previa en vivo'; + + @override + String get refreshSchedule => 'Programación de actualización'; + + @override + String get noResponse => 'Sin respuesta'; + + @override + String get refreshing => 'Actualizando'; + + @override + String get unavailable => 'No disponible'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Elija un relay o nodo para observar.'; + + @override + String get publicKeyLabel => 'Clave pública'; + + @override + String get alreadyInContacts => 'Ya en contactos'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Conéctese a un dispositivo antes de añadir contactos'; + + @override + String get fromContacts => 'De contactos'; + + @override + String get onlineOnly => 'Solo en línea'; + + @override + String get inBoth => 'En ambos'; + + @override + String get source => 'Fuente'; + + @override + String get allMessagesCleared => 'Todos los mensajes borrados'; + + @override + String get onlineTraceDatabaseCleared => 'Base de datos de trazas borrada'; + + @override + String get packetLogsCleared => 'Registros de paquetes borrados'; + + @override + String get hexDataCopiedToClipboard => 'Datos hex copiados al portapapeles'; + + @override + String get developerModeEnabled => 'Modo desarrollador activado'; + + @override + String get developerModeDisabled => 'Modo desarrollador desactivado'; + + @override + String get clipboardIsEmpty => 'El portapapeles está vacío'; + + @override + String get contactImported => 'Contacto importado'; + + @override + String get contactLinkCopiedToClipboard => + 'Enlace de contacto copiado al portapapeles'; + + @override + String get failedToExportContact => 'Error al exportar el contacto'; + + @override + String get noLogsToExport => 'No hay registros para exportar'; + + @override + String get exportAsCsv => 'Exportar como CSV'; + + @override + String get exportAsText => 'Exportar como texto'; + + @override + String get receivedRfc3339 => 'Recibido (RFC3339)'; + + @override + String get buildTime => 'Hora de compilación'; + + @override + String get downloadUrlNotAvailable => 'URL de descarga no disponible'; + + @override + String get cannotOpenDownloadUrl => 'No se puede abrir la URL de descarga'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'La comprobación de actualizaciones solo está disponible en Android'; + + @override + String get youAreRunningTheLatestVersion => + 'Está ejecutando la última versión'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Actualización disponible pero URL de descarga no encontrada'; + + @override + String get startTictactoe => 'Iniciar Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe no disponible'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: oponente desconocido'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: esperando inicio'; + + @override + String get acceptsShareLinks => 'Acepta enlaces compartidos'; + + @override + String get supportsRawHex => 'Soporta hex sin procesar'; + + @override + String get clipboardfriendly => 'Compatible con portapapeles'; + + @override + String get captured => 'Capturado'; + + @override + String get size => 'Tamaño'; + + @override + String get noCustomChannelsToClear => + 'No hay canales personalizados que borrar.'; + + @override + String get noDeviceContactsToClear => + 'No hay contactos del dispositivo que borrar.'; + + @override + String get aToZ => 'A-Z'; + + @override + String get add => 'Agregar'; + + @override + String get addAdditionalDetails => 'Agregar detalles adicionales'; + + @override + String get addPeer => 'Agregar par'; + + @override + String get addPeerManually => 'Agregar par manualmente'; + + @override + String addedToContacts(String name) { + return '$name agregado a contactos'; + } + + @override + String addedToSensors(String name) { + return '$name agregado a Sensores'; + } + + @override + String get allowAll => 'Permitir todos'; + + @override + String get anonymousRxStats => 'Estadísticas RX anónimas'; + + @override + String get appearance => 'Apariencia'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Telemetría base'; + + @override + String get blePin => 'PIN BLE'; + + @override + String get buzzerAlerts => 'Alertas de zumbador'; + + @override + String get cancelImageReceive => 'Cancelar recepción de imagen'; + + @override + String get choose => 'Elegir'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Elige qué pestañas y secciones de contactos mostrar'; + + @override + String get clearCache => 'Limpiar caché'; + + @override + String get clearHistory => 'Limpiar historial'; + + @override + String get clearLiveView => 'Limpiar vista en vivo'; + + @override + String get clearLogs => 'Limpiar registros'; + + @override + String get clearOfflineCache => '¿Limpiar caché sin conexión?'; + + @override + String get clearOverlay => 'Limpiar superposición'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Limpiar ruta solo después de que fallen todos los reintentos'; + + @override + String get codecBitrateAndAudioProcessing => + 'Códec, tasa de bits y procesamiento de audio'; + + @override + String get connectByIpAddress => 'Conectar por dirección IP'; + + @override + String get controlWhichAlertsYouReceive => 'Controla qué alertas recibes'; + + @override + String get copyFullHex => 'Copiar Hex completo'; + + @override + String get copyRawResponse => 'Copiar respuesta sin procesar'; + + @override + String get couldNotLoadMetHistory => 'No se pudo cargar el historial MET'; + + @override + String customizeSensor(String name) { + return 'Personalizar $name'; + } + + @override + String get data => 'Datos'; + + @override + String get deleteGroup => 'Eliminar grupo'; + + @override + String deleteStyleConfirm(String name) { + return '¿Eliminar $name?'; + } + + @override + String deleteThisDrawing(String type) { + return '¿Eliminar este $type?'; + } + + @override + String get deny => 'Denegar'; + + @override + String get deviceInfo => 'Información del dispositivo'; + + @override + String get deviceSettings => 'Configuración del dispositivo'; + + @override + String get deviceTimeSynced => + 'Hora del dispositivo sincronizada con este teléfono.'; + + @override + String get disableFastGpsPublishing => 'Desactivar publicación rápida de GPS'; + + @override + String get displayMarkersAndTraceDatabase => + 'Pantalla, marcadores y base de datos de trazas'; + + @override + String get download => 'Descargar'; + + @override + String get environmentalTelemetry => 'Telemetría ambiental'; + + @override + String errorCheckingForUpdates(String error) { + return 'Error al buscar actualizaciones: $error'; + } + + @override + String errorGeneric(String error) { + return 'Error: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Error al abrir la descarga: $error'; + } + + @override + String get exportLogs => 'Exportar registros'; + + @override + String get extraAggressiveAvifCompression => 'Compresión AVIF extra agresiva'; + + @override + String failedToAddContact(String error) { + return 'Error al agregar contacto: $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Error al agregar $name: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Error al limpiar canales: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Error al limpiar contactos: $error'; + } + + @override + String get failedToConnectViaSerial => 'Error al conectar por serial'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Error al cargar imagen de vista previa: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Error al cargar traza: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Error al enviar marcador SAR: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Error al configurar modo de zumbador: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Error al configurar modo GPS: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Error al sincronizar hora del dispositivo: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Error al borrar datos del dispositivo: $error'; + } + + @override + String get fastGpsTargetChannel => 'Canal objetivo de GPS rápido'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Actualizaciones rápidas, umbrales y permisos'; + + @override + String get fetchingCatalog => 'Obteniendo catálogo...'; + + @override + String get fifteenMinutes => '15 minutos'; + + @override + String get fifteenSeconds => '15 segundos'; + + @override + String get filterByDirection => 'Filtrar por dirección'; + + @override + String get finish => 'Finalizar'; + + @override + String get fiveMinutes => '5 minutos'; + + @override + String get fiveSeconds => '5 segundos'; + + @override + String get fix => 'Posición'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'Política de anuncio GPS'; + + @override + String get gpsAndLocation => 'GPS y ubicación'; + + @override + String get gpsDiagnostics => 'Diagnósticos GPS'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Ayuda'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Oculto'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Cómo la radio agrega automáticamente nodos descubiertos.'; + + @override + String get images => 'Imágenes'; + + @override + String get importProfile => 'Importar perfil'; + + @override + String get lastFix => 'Última posición'; + + @override + String get latest => 'Más reciente'; + + @override + String get latitude => 'Latitud'; + + @override + String get loadASavedRegion => 'Cargar una región guardada'; + + @override + String get loading => 'Cargando'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Compartir ubicación, hardware e intervalo de actualización.'; + + @override + String get locationTelemetry => 'Telemetría de ubicación'; + + @override + String get lockDestination => 'Bloquear destino'; + + @override + String get longitude => 'Longitud'; + + @override + String get mapStyle => 'Estilo de mapa'; + + @override + String get max => 'Máx'; + + @override + String get maxZoom => 'Zoom máximo'; + + @override + String get messaging => 'Mensajería'; + + @override + String get metHistory => 'Historial MET'; + + @override + String get min => 'Mín'; + + @override + String get minZoom => 'Zoom mínimo'; + + @override + String get moveDown => 'Mover abajo'; + + @override + String get moveUp => 'Mover arriba'; + + @override + String get multiAckMode => 'Modo multi-ACK'; + + @override + String get multiDeviceWorkspaceManagement => + 'Gestión de espacio de trabajo multidispositivo'; + + @override + String get myLocation => 'Mi ubicación'; + + @override + String get nameAndTelemetryShared => + 'Nombre y telemetría compartidos con otros dispositivos.'; + + @override + String newVersionAvailable(String version) { + return 'Nueva versión: $version'; + } + + @override + String get newer => 'Más nuevo'; + + @override + String get noNeighboursFound => 'No se encontraron vecinos'; + + @override + String get noServersFound => 'No se encontraron servidores'; + + @override + String get notSet => 'Sin configurar'; + + @override + String get notifications => 'Notificaciones'; + + @override + String get offZeroSeconds => 'Apagado (0 s)'; + + @override + String get offlineMaps => 'Mapas sin conexión'; + + @override + String get older => 'Más antiguo'; + + @override + String get oneByteMode0 => '1 byte (modo 0)'; + + @override + String get oneHour => '1 hora'; + + @override + String get oneMinute => '1 minuto'; + + @override + String get openPacketLogs => 'Abrir registros de paquetes'; + + @override + String get packetTypeHelp => 'Ayuda de tipo de paquete'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Pega un enlace compartido o anuncio hex'; + + @override + String get pathHash => 'Hash de ruta'; + + @override + String get pathSize => 'Tamaño de ruta'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Polígono'; + + @override + String get rawResponseCopied => 'Respuesta sin procesar copiada'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Reenviar a través del repetidor más cercano en caso de fallo'; + + @override + String get resolveContact => 'Resolver contacto'; + + @override + String get routingRetriesAndDestinationLock => + 'Enrutamiento, reintentos y bloqueo de destino'; + + @override + String get samples => 'Muestras'; + + @override + String get satellites => 'Satélites'; + + @override + String get saving => 'Guardando...'; + + @override + String get search => 'Buscar'; + + @override + String get searchByNameOrToken => 'Buscar por nombre o token'; + + @override + String get searchDiscoveredNodes => 'Buscar nodos descubiertos'; + + @override + String get searchInCurrentFilter => 'Buscar en el filtro actual'; + + @override + String get searchLogs => 'Buscar registros...'; + + @override + String get searchSensors => 'Buscar sensores'; + + @override + String get searchThisSection => 'Buscar en esta sección'; + + @override + String get selectedRelays => 'Relés seleccionados'; + + @override + String get sensor => 'Sensor'; + + @override + String serialError(String error) { + return 'Error serial: $error'; + } + + @override + String setPathForContact(String name) { + return 'Establecer ruta para $name'; + } + + @override + String get shareLiveGps => 'Compartir GPS en vivo'; + + @override + String get shareMyTiles => 'Compartir mis mosaicos'; + + @override + String get sharingLocationFromPhone => + 'Compartiendo ubicación en este canal desde el teléfono.'; + + @override + String get sharingLocationFromRadio => + 'Compartiendo ubicación en este canal desde la radio.'; + + @override + String signalDbm(String rssi) { + return 'Señal $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => 'Tamaño, compresión y vista previa'; + + @override + String get simpleMode => 'Modo simple'; + + @override + String get simpleModeDescription => + 'Solo chat y mapa con contactos favoritos'; + + @override + String get simpleModeRequiresLockedDestination => + 'Activa primero «Bloquear destino» con un canal para usar el modo simple'; + + @override + String get noFavouriteContacts => 'Sin contactos favoritos'; + + @override + String get skip => 'Omitir'; + + @override + String get smallerFileSize => 'Tamaño de archivo más pequeño'; + + @override + String get sort => 'Ordenar'; + + @override + String get stoppedSharingLocation => + 'Se dejó de compartir ubicación en este canal.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Suprimir notificaciones en primer plano'; + + @override + String get tabsAndNavigation => 'Pestañas y navegación'; + + @override + String get templatesAndTutorials => 'Plantillas y tutoriales'; + + @override + String get tenMinutes => '10 minutos'; + + @override + String get tenSeconds => '10 segundos'; + + @override + String get testSendUpdate => 'Probar envío de actualización'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Tema, idioma y preferencias de visualización'; + + @override + String get thirtyMinutes => '30 minutos'; + + @override + String get thirtySeconds => '30 segundos'; + + @override + String get threeBytesMode2 => '3 bytes (modo 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Estadísticas de tráfico, historial de mensajes y herramientas de desarrollo'; + + @override + String get twoBytesMode1 => '2 bytes (modo 1)'; + + @override + String get twoMinutes => '2 minutos'; + + @override + String get undo => 'Deshacer'; + + @override + String get updated => 'Actualizado'; + + @override + String get useContactFlags => 'Usar banderas de contacto'; + + @override + String get useSavedCoordinates => 'Usar coordenadas guardadas'; + + @override + String get view => 'Ver'; + + @override + String get viewNeighbours => 'Ver vecinos'; + + @override + String get viewPublicStats => 'Ver estadísticas públicas'; + + @override + String get setRegionScope => 'Establecer ámbito de región'; + + @override + String get regionScope => 'Ámbito de región'; + + @override + String get regionScopeNone => 'Ninguno (global)'; + + @override + String get clearRegionScope => 'Limpiar ámbito'; + + @override + String get regionScopeWarning => + 'Solo los repetidores que permitan esta región reenviarán.'; + + @override + String get discoverRegions => 'Descubrir desde repetidores'; + + @override + String get discoveringRegions => 'Descubriendo regiones...'; + + @override + String get enterRegionName => 'Nombre de región (ej. auckland)'; + + @override + String get noRegionsFound => 'No se encontraron regiones en este repetidor.'; + + @override + String regionScopeSet(String name) { + return 'Ámbito de región establecido a $name'; + } + + @override + String get regionScopeCleared => 'Ámbito de región limpiado'; + + @override + String get selectDestinationFirst => + 'Selecciona primero un canal, contacto o sala'; + + @override + String get recipientNoLongerAvailable => + 'El destinatario ya no está disponible'; + + @override + String failedToSendMessage(String error) { + return 'Error al enviar: $error'; + } + + @override + String get tictactoeDirectOnly => + 'El tres en raya solo funciona en mensajes directos. Elige primero un contacto.'; + + @override + String get deviceKeyUnavailable => + 'La clave del dispositivo no está disponible'; + + @override + String get imageCompressionFailed => 'Error al comprimir la imagen'; + + @override + String get imageFragmentationFailed => 'Error al fragmentar la imagen'; + + @override + String get failedToAnnounceImage => 'Error al anunciar la imagen'; + + @override + String get noRecipientSelected => 'Ningún destinatario seleccionado'; + + @override + String get imageSendFailed => 'Error al enviar la imagen'; + + @override + String get microphonePermissionRequiredForVoice => + 'Se necesita permiso de micrófono para los mensajes de voz'; + + @override + String get recordingDiscarded => 'Grabación descartada'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Estás a punto de enviar $mediaType al canal público. No es recomendable, porque cualquiera en la red mesh podría recibirlo. Elige un canal privado o etiquetado, salvo que sea lo que quieres.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Selecciona un canal privado para compartir tu ubicación.'; + + @override + String get stopSharingMyLocation => 'Dejar de compartir mi ubicación'; + + @override + String get shareMyLocation => 'Compartir mi ubicación'; + + @override + String get stopRecording => 'Detener grabación'; + + @override + String get recordVoice => 'Grabar voz'; + + @override + String get moreActions => 'Más acciones'; + + @override + String get noMessagesInFilter => 'No hay mensajes en este filtro'; + + @override + String get noMatchesInFilter => 'No hay coincidencias en este filtro'; + + @override + String syncFailed(String error) { + return 'Error de sincronización: $error'; + } + + @override + String get invalidLink => 'Enlace no válido'; + + @override + String get cannotOpenLink => 'No se puede abrir el enlace'; + + @override + String get failedToOpenLink => 'Error al abrir el enlace'; + + @override + String get failedToResendMessage => 'Error al reenviar el mensaje'; + + @override + String retryFailed(String error) { + return 'Error al reintentar: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Eliminar dibujo y mensaje'; + + @override + String get lastEchoRelay => 'Último eco: repetidor'; + + @override + String get lastEchoPath => 'Último eco: ruta'; + + @override + String get lastEchoBytesReport => 'Último eco: informe de bytes'; + + @override + String get noGpsCoordinatesAvailable => 'No hay coordenadas GPS disponibles'; + + @override + String get notASarMarker => 'No es un marcador SAR'; + + @override + String failedToSaveTemplate(String error) { + return 'Error al guardar la plantilla: $error'; + } + + @override + String get drawingNotFound => 'Dibujo no encontrado'; + + @override + String get unknownDrawingType => 'Tipo de dibujo desconocido'; + + @override + String get customMapMarker => 'Marcador de mapa personalizado'; + + @override + String customMapPointLabel(String point) { + return 'Punto: $point'; + } + + @override + String mapIdLabel(String id) { + return 'ID del mapa: $id'; + } + + @override + String get cannotFetchVoice => 'No se puede obtener la voz'; + + @override + String get senderContactUnknown => + 'El contacto del remitente es desconocido. Sincroniza primero los contactos.'; + + @override + String get senderRouteUnknown => + 'La ruta del remitente es desconocida. Sincroniza primero los contactos/la ruta.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'El mensaje está demasiado lejos ($hops saltos, máx. $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'La ruta del remitente no respondió a la comprobación de ruta. Sincroniza los contactos/la ruta e inténtalo de nuevo.'; + + @override + String get senderRouteNoRawResponse => + 'La ruta del remitente no respondió en la ruta de transporte raw.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Obtener la voz a través de $hops saltos puede tardar un poco.'; + } + + @override + String get voiceReceiveCanceled => 'Recepción de voz cancelada'; + + @override + String get fetchingMissingVoiceFragments => + 'Obteniendo fragmentos de voz que faltan'; + + @override + String get receivingVoice => 'Recibiendo voz'; + + @override + String get cannotFetchImage => 'No se puede obtener la imagen'; + + @override + String imageFetchOverHops(String hops) { + return 'Obtener la imagen a través de $hops saltos puede tardar un poco.'; + } + + @override + String get imageAlreadyBeingReceived => 'La imagen ya se está recibiendo'; + + @override + String get loadImage => 'Cargar imagen'; + + @override + String get imageFetchFailedToSendRequest => + 'No se pudo enviar la solicitud de imagen'; + + @override + String get imageUnavailable => 'Imagen no disponible por ahora'; + + @override + String get imageFetchTimedOut => + 'La obtención de la imagen agotó el tiempo de espera'; + + @override + String get imageReceiveCanceled => 'Recepción de imagen cancelada'; + + @override + String get closeImagePreview => 'Cerrar vista previa de imagen'; + + @override + String get recordingReleaseToSend => 'Grabando... suelta para enviar la voz'; + + @override + String get sendingVoice => 'Enviando voz...'; + + @override + String get sendLongPressToRecordVoice => + 'Enviar (mantén pulsado para grabar voz)'; + + @override + String get discardRecording => 'Descartar grabación'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count saltos', + one: '1 salto', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Canales de difusión para el tráfico mesh cercano'; + + @override + String get roomsSectionDescription => + 'Espacios compartidos para la coordinación del equipo'; + + @override + String get contactsSectionDescription => + 'Personas y dispositivos a tu alcance directo'; + + @override + String get noRecentChatters => 'Sin participantes recientes'; + + @override + String get public => 'Público'; + + @override + String get failedToSendTicTacToeMove => + 'No se pudo enviar el movimiento de tres en raya'; + + @override + String get youWon => 'Has ganado'; + + @override + String get opponentWon => 'Ha ganado el rival'; + + @override + String get gameDraw => 'Empate'; + + @override + String get yourTurn => 'Te toca'; + + @override + String get opponentTurn => 'Le toca al rival'; + + @override + String get pleaseEnterValidCoordinates => 'Introduce coordenadas válidas'; + + @override + String get locationNotAvailable => 'Ubicación no disponible'; + + @override + String get customCaveMapPoint => 'Punto de mapa de cueva personalizado'; + + @override + String get mapPoint => 'Punto del mapa'; + + @override + String get fetchingMissingImageFragments => + 'Obteniendo fragmentos que faltan…'; + + @override + String get loadingImage => 'Cargando…'; + + @override + String get receivingImage => 'Recibiendo…'; + + @override + String get tapToLoad => 'Toca para cargar'; + + @override + String get connectDevice => 'Conectar dispositivo'; + + @override + String get chooseTransportSubtitle => + 'Elige el transporte Bluetooth, WiFi o Serie'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Red'; + + @override + String get serial => 'Serie'; + + @override + String get scan => 'Buscar'; + + @override + String get pressScanToSearchForDevices => + 'Pulsa Buscar para encontrar dispositivos cercanos'; + + @override + String failedToConnectToDevice(String name) { + return 'Error al conectar con $name'; + } + + @override + String get unknownDevice => 'Dispositivo desconocido'; + + @override + String get showingCachedResultsTapRefresh => + 'Mostrando resultados en caché. Toca actualizar para volver a buscar.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Buscando dispositivos MeshCore WiFi en la red local en el puerto $port'; + } + + @override + String get cancelScanAndAddServer => 'Cancelar búsqueda y añadir servidor'; + + @override + String get addServer => 'Añadir servidor'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Buscando... $scanned/$total IP'; + } + + @override + String get cancelAndEnterManually => 'Cancelar e introducir manualmente'; + + @override + String get noRecentOrDiscoveredServers => + 'Aún no hay servidores recientes ni descubiertos'; + + @override + String get recentlyUsed => 'Usados recientemente'; + + @override + String get connecting => 'Conectando...'; + + @override + String get discoveredOnThisNetwork => 'Descubiertos en esta red'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'El servidor en $host:$port ya no está disponible. Vuelve a buscar para encontrar servidores activos.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Error al conectar con $host:$port'; + } + + @override + String get enterValidIpAddress => 'Introduce una dirección IP válida'; + + @override + String get enterValidTcpPort => 'Introduce un puerto TCP válido'; + + @override + String get ipAddress => 'Dirección IP'; + + @override + String get tcpPort => 'Puerto TCP'; + + @override + String get customServerPort => 'Puerto de servidor personalizado'; + + @override + String get chat => 'Chat'; + + @override + String get restoringPreviousLink => 'Restaurando el enlace anterior'; + + @override + String get noDeviceConnected => 'Ningún dispositivo conectado'; + + @override + String get sendAdvert => 'Enviar mi contacto'; + + @override + String get advertMode => 'Enviar mi contacto'; + + @override + String get advertModeSubtitle => + 'Elige hasta dónde debe llegar tu información de contacto.'; + + @override + String get floodAdvertSent => 'Contacto enviado a través de la red mesh'; + + @override + String get directAdvertSent => 'Contacto enviado a dispositivos cercanos'; +} diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart new file mode 100644 index 0000000..6d03b1c --- /dev/null +++ b/lib/l10n/app_localizations_fr.dart @@ -0,0 +1,3470 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for French (`fr`). +class AppLocalizationsFr extends AppLocalizations { + AppLocalizationsFr([String locale = 'fr']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Messages'; + + @override + String get contacts => 'Contacts'; + + @override + String get map => 'Carte'; + + @override + String get settings => 'Paramètres'; + + @override + String get connect => 'Connecter'; + + @override + String get disconnect => 'Déconnecter'; + + @override + String get noDevicesFound => 'Aucun appareil trouvé'; + + @override + String get scanAgain => 'Rechercher à nouveau'; + + @override + String get deviceNotConnected => 'Appareil non connecté'; + + @override + String get locationPermissionDenied => 'Permission de localisation refusée'; + + @override + String get locationPermissionPermanentlyDenied => + 'Permission de localisation définitivement refusée. Veuillez l\'activer dans les Paramètres.'; + + @override + String get locationPermissionRequired => + 'La permission de localisation est requise pour le suivi GPS et la coordination d\'équipe. Vous pouvez l\'activer plus tard dans les Paramètres.'; + + @override + String get locationServicesDisabled => + 'Les services de localisation sont désactivés. Veuillez les activer dans les Paramètres.'; + + @override + String get failedToGetGpsLocation => + 'Échec de l\'obtention de la position GPS'; + + @override + String failedToAdvertise(String error) { + return 'Échec de l\'annonce : $error'; + } + + @override + String get cancelReconnection => 'Annuler la reconnexion'; + + @override + String get general => 'Général'; + + @override + String get theme => 'Thème'; + + @override + String get chooseTheme => 'Choisir le thème'; + + @override + String get light => 'Clair'; + + @override + String get dark => 'Sombre'; + + @override + String get blueLightTheme => 'Thème bleu clair'; + + @override + String get blueDarkTheme => 'Thème bleu sombre'; + + @override + String get sarRed => 'SAR Rouge'; + + @override + String get alertEmergencyMode => 'Mode alerte/urgence'; + + @override + String get sarGreen => 'SAR Vert'; + + @override + String get safeAllClearMode => 'Mode sécurisé/dégagé'; + + @override + String get autoSystem => 'Auto (Système)'; + + @override + String get followSystemTheme => 'Suivre le thème du système'; + + @override + String get showRxTxIndicators => 'Afficher les indicateurs RX/TX'; + + @override + String get disableMap => 'Désactiver la carte'; + + @override + String get language => 'Langue'; + + @override + String get chooseLanguage => 'Choisir la langue'; + + @override + String get save => 'Enregistrer'; + + @override + String get cancel => 'Annuler'; + + @override + String get close => 'Fermer'; + + @override + String get about => 'À propos'; + + @override + String get appVersion => 'Version de l\'application'; + + @override + String get aboutMeshCoreSar => 'À propos de MeshCore SAR'; + + @override + String get aboutDescription => + 'Une application de recherche et sauvetage conçue pour les équipes d\'intervention d\'urgence. Les fonctionnalités incluent :\n\n• Réseau maillé BLE pour communication appareil à appareil\n• Cartes hors ligne avec options de couches multiples\n• Suivi en temps réel des membres de l\'équipe\n• Marqueurs tactiques SAR (personne trouvée, feu, zone de rassemblement)\n• Gestion des contacts et messagerie\n• Suivi GPS avec cap du compas\n• Mise en cache des tuiles de carte pour utilisation hors ligne'; + + @override + String get technologiesUsed => 'Technologies utilisées :'; + + @override + String get technologiesList => + '• Flutter pour le développement multiplateforme\n• BLE (Bluetooth Low Energy) pour réseau maillé\n• OpenStreetMap pour la cartographie\n• Provider pour la gestion d\'état\n• SharedPreferences pour le stockage local'; + + @override + String get moreInfo => 'Plus d\'infos'; + + @override + String get packageName => 'Nom du package'; + + @override + String get sampleData => 'Données d\'exemple'; + + @override + String get sampleDataDescription => + 'Charger ou effacer les contacts d\'exemple, les messages de canal et les marqueurs SAR pour les tests'; + + @override + String get loadSampleData => 'Charger des données d\'exemple'; + + @override + String get clearAllData => 'Effacer toutes les données'; + + @override + String get clearAllDataConfirmTitle => 'Effacer toutes les données'; + + @override + String get clearAllDataConfirmMessage => + 'Cela effacera tous les contacts et marqueurs SAR. Êtes-vous sûr ?'; + + @override + String get clear => 'Effacer'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Chargé $teamCount membres d\'équipe, $channelCount canaux, $sarCount marqueurs SAR, $messageCount messages'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Échec du chargement des données d\'exemple : $error'; + } + + @override + String get allDataCleared => 'Toutes les données effacées'; + + @override + String get failedToStartBackgroundTracking => + 'Échec du démarrage du suivi en arrière-plan. Vérifiez les permissions et la connexion BLE.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Diffusion de position : $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'Le code PIN par défaut pour les appareils sans écran est 123456. Problèmes d\'appairage ? Oubliez l\'appareil Bluetooth dans les paramètres système.'; + + @override + String get noMessagesYet => 'Aucun message pour le moment'; + + @override + String get pullDownToSync => + 'Tirez vers le bas pour synchroniser les messages'; + + @override + String get deleteContact => 'Supprimer le contact'; + + @override + String get delete => 'Supprimer'; + + @override + String get viewOnMap => 'Voir sur la carte'; + + @override + String get refresh => 'Actualiser'; + + @override + String get resetPath => 'Réinitialiser le chemin (Re-router)'; + + @override + String copiedToClipboard(String label) { + return '$label copié dans le presse-papiers'; + } + + @override + String get pleaseEnterPassword => 'Veuillez saisir un mot de passe'; + + @override + String failedToSyncContacts(String error) { + return 'Échec de la synchronisation des contacts : $error'; + } + + @override + String get loggedInSuccessfully => + 'Connexion réussie ! En attente des messages du salon...'; + + @override + String get loginFailed => 'Échec de la connexion - mot de passe incorrect'; + + @override + String loggingIn(String roomName) { + return 'Connexion à $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Échec de l\'envoi de la connexion : $error'; + } + + @override + String get lowLocationAccuracy => 'Précision de localisation faible'; + + @override + String get continue_ => 'Continuer'; + + @override + String get sendSarMarker => 'Envoyer un marqueur SAR'; + + @override + String get deleteDrawing => 'Supprimer le dessin'; + + @override + String get drawingTools => 'Outils de Dessin'; + + @override + String get drawLine => 'Tracer une ligne'; + + @override + String get drawLineDesc => 'Tracer une ligne à main levée sur la carte'; + + @override + String get drawRectangle => 'Tracer un rectangle'; + + @override + String get drawRectangleDesc => 'Tracer une zone rectangulaire sur la carte'; + + @override + String get measureDistance => 'Mesurer la distance'; + + @override + String get measureDistanceDesc => 'Appui long sur deux points pour mesurer'; + + @override + String get clearMeasurement => 'Effacer la mesure'; + + @override + String distanceLabel(String distance) { + return 'Distance : $distance'; + } + + @override + String get longPressForSecondPoint => 'Appui long pour le deuxième point'; + + @override + String get longPressToStartMeasurement => + 'Appui long pour définir le premier point'; + + @override + String get longPressToStartNewMeasurement => + 'Appui long pour nouvelle mesure'; + + @override + String get shareDrawings => 'Partager les dessins'; + + @override + String get clearAllDrawings => 'Effacer tous les dessins'; + + @override + String get completeLine => 'Terminer la ligne'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Diffuser $count dessin$plural à l\'équipe'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Supprimer tous les $count dessin$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Supprimer tous les $count dessin$plural de la carte ?'; + } + + @override + String get drawing => 'Dessin'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Partager $count dessin$plural'; + } + + @override + String get showReceivedDrawings => 'Afficher les dessins reçus'; + + @override + String get showingAllDrawings => 'Affichage de tous les dessins'; + + @override + String get showingOnlyYourDrawings => 'Affichage uniquement de vos dessins'; + + @override + String get showSarMarkers => 'Afficher les marqueurs SAR'; + + @override + String get showingSarMarkers => 'Affichage des marqueurs SAR'; + + @override + String get hidingSarMarkers => 'Masquage des marqueurs SAR'; + + @override + String get clearAll => 'Tout effacer'; + + @override + String get publicChannel => 'Canal public'; + + @override + String get broadcastToAll => + 'Diffuser à tous les nœuds à proximité (éphémère)'; + + @override + String get storedPermanently => 'Stocké de manière permanente dans le salon'; + + @override + String get notConnectedToDevice => 'Non connecté à l\'appareil'; + + @override + String get typeYourMessage => 'Saisissez votre message...'; + + @override + String get quickLocationMarker => 'Marqueur de position rapide'; + + @override + String get markerType => 'Type de marqueur'; + + @override + String get sendTo => 'Envoyer à'; + + @override + String get noDestinationsAvailable => 'Aucune destination disponible.'; + + @override + String get selectDestination => 'Sélectionner la destination...'; + + @override + String get ephemeralBroadcastInfo => + 'Éphémère : Diffusion par ondes uniquement. Non stocké - les nœuds doivent être en ligne.'; + + @override + String get persistentRoomInfo => + 'Persistant : Stocké de manière immuable dans le salon. Synchronisé automatiquement et préservé hors ligne.'; + + @override + String get location => 'Position'; + + @override + String get fromMap => 'Depuis la carte'; + + @override + String get gettingLocation => 'Obtention de la position...'; + + @override + String get locationError => 'Erreur de localisation'; + + @override + String get retry => 'Réessayer'; + + @override + String get refreshLocation => 'Actualiser la position'; + + @override + String accuracyMeters(int accuracy) { + return 'Précision : ±${accuracy}m'; + } + + @override + String get notesOptional => 'Notes (facultatives)'; + + @override + String get addAdditionalInformation => + 'Ajouter des informations supplémentaires...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'La précision de localisation est de ±${accuracy}m. Cela peut ne pas être assez précis pour les opérations SAR.\n\nContinuer quand même ?'; + } + + @override + String get loginToRoom => 'Se connecter au salon'; + + @override + String get enterPasswordInfo => + 'Entrez le mot de passe pour accéder à ce salon. Le mot de passe sera enregistré pour une utilisation future.'; + + @override + String get password => 'Mot de passe'; + + @override + String get enterRoomPassword => 'Entrez le mot de passe du salon'; + + @override + String get loggingInDots => 'Connexion...'; + + @override + String get login => 'Se connecter'; + + @override + String failedToAddRoom(String error) { + return 'Échec de l\'ajout du salon à l\'appareil : $error\n\nLe salon n\'a peut-être pas encore été annoncé.\nEssayez d\'attendre que le salon diffuse.'; + } + + @override + String get direct => 'Direct'; + + @override + String get flood => 'Inondation'; + + @override + String get autoSend => 'Envoi automatique'; + + @override + String get autoSendDescription => 'Envoyer via le chemin actuel.'; + + @override + String get sendDirect => 'Envoi direct'; + + @override + String get sendDirectDescription => 'Envoyer directement à ce contact.'; + + @override + String get sendFlood => 'Envoi par inondation'; + + @override + String get sendFloodDescription => 'Envoyer via tous les relais.'; + + @override + String get loggedIn => 'Connecté'; + + @override + String get noGpsData => 'Aucune donnée GPS'; + + @override + String get distance => 'Distance'; + + @override + String directPingTimeout(String name) { + return 'Délai d\'attente du ping direct - nouvelle tentative de $name avec inondation...'; + } + + @override + String pingFailed(String name) { + return 'Échec du ping vers $name - aucune réponse reçue'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Êtes-vous sûr de vouloir supprimer \"$name\" ?\n\nCela supprimera le contact de l\'application et de l\'appareil radio compagnon.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Échec de la suppression du contact : $error'; + } + + @override + String get type => 'Type'; + + @override + String get publicKey => 'Clé publique'; + + @override + String get lastSeen => 'Dernière vue'; + + @override + String get yes => 'Oui'; + + @override + String get no => 'Non'; + + @override + String get permissions => 'Permissions'; + + @override + String get telemetry => 'Télémétrie'; + + @override + String get voltage => 'Tension'; + + @override + String get battery => 'Batterie'; + + @override + String get temperature => 'Température'; + + @override + String get humidity => 'Humidité'; + + @override + String get pressure => 'Pression'; + + @override + String get gpsTelemetry => 'GPS (Télémétrie)'; + + @override + String get reLoginToRoom => 'Se reconnecter au salon'; + + @override + String get heading => 'Cap'; + + @override + String get elevation => 'Élévation'; + + @override + String get accuracy => 'Précision'; + + @override + String get bearing => 'Relèvement'; + + @override + String get direction => 'Direction'; + + @override + String get filterMarkers => 'Filtrer les marqueurs'; + + @override + String get filterMarkersTooltip => 'Filtrer les marqueurs'; + + @override + String get contactsFilter => 'Contacts'; + + @override + String get repeatersFilter => 'Répéteurs'; + + @override + String get sarMarkers => 'Marqueurs SAR'; + + @override + String get foundPerson => 'Personne trouvée'; + + @override + String get fire => 'Feu'; + + @override + String get stagingArea => 'Zone de rassemblement'; + + @override + String get showAll => 'Tout afficher'; + + @override + String get locationUnavailable => 'Position non disponible'; + + @override + String get ahead => 'devant'; + + @override + String degreesRight(int degrees) { + return '$degrees° à droite'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° à gauche'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Lat : $latitude Lon : $longitude'; + } + + @override + String get noContactsYet => 'Aucun contact pour le moment'; + + @override + String get connectToDeviceToLoadContacts => + 'Connectez-vous à un appareil pour charger les contacts'; + + @override + String get teamMembers => 'Membres de l\'équipe'; + + @override + String get repeaters => 'Répéteurs'; + + @override + String get rooms => 'Salons'; + + @override + String get channels => 'Canaux'; + + @override + String get selectMapLayer => 'Sélectionner la couche de carte'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'Satellite ESRI'; + + @override + String get googleHybrid => 'Google Hybride'; + + @override + String get googleRoadmap => 'Google Carte Routière'; + + @override + String get googleTerrain => 'Google Terrain'; + + @override + String get dragToPosition => 'Faire glisser vers la position'; + + @override + String get createSarMarker => 'Créer un marqueur SAR'; + + @override + String get compass => 'Boussole'; + + @override + String get navigationAndContacts => 'Navigation et contacts'; + + @override + String get sarAlert => 'ALERTE SAR'; + + @override + String get textCopiedToClipboard => 'Texte copié dans le presse-papiers'; + + @override + String get cannotReplySenderMissing => + 'Impossible de répondre : informations sur l\'expéditeur manquantes'; + + @override + String get cannotReplyContactNotFound => + 'Impossible de répondre : contact non trouvé'; + + @override + String get copyText => 'Copier le texte'; + + @override + String get saveAsTemplate => 'Enregistrer comme Modèle'; + + @override + String get templateSaved => 'Modèle enregistré avec succès'; + + @override + String get templateAlreadyExists => 'Un modèle avec cet emoji existe déjà'; + + @override + String get deleteMessage => 'Supprimer le message'; + + @override + String get deleteMessageConfirmation => + 'Êtes-vous sûr de vouloir supprimer ce message?'; + + @override + String get shareLocation => 'Partager la position'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nCoordonnées: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'Position SAR'; + + @override + String get justNow => 'À l\'instant'; + + @override + String minutesAgo(int minutes) { + return 'Il y a ${minutes}m'; + } + + @override + String hoursAgo(int hours) { + return 'Il y a ${hours}h'; + } + + @override + String daysAgo(int days) { + return 'Il y a ${days}j'; + } + + @override + String secondsAgo(int seconds) { + return 'Il y a ${seconds}s'; + } + + @override + String get sending => 'Envoi...'; + + @override + String get sent => 'Envoyé'; + + @override + String get delivered => 'Livré'; + + @override + String deliveredWithTime(int time) { + return 'Livré (${time}ms)'; + } + + @override + String get failed => 'Échec'; + + @override + String get broadcast => 'Diffusion'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Livré à $delivered/$total contacts'; + } + + @override + String get allDelivered => 'Tout livré'; + + @override + String get recipientDetails => 'Détails des destinataires'; + + @override + String get pending => 'En attente'; + + @override + String get sarMarkerFoundPerson => 'Personne trouvée'; + + @override + String get sarMarkerFire => 'Lieu de feu'; + + @override + String get sarMarkerStagingArea => 'Zone de rassemblement'; + + @override + String get sarMarkerObject => 'Objet trouvé'; + + @override + String get from => 'De'; + + @override + String get coordinates => 'Coordonnées'; + + @override + String get tapToViewOnMap => 'Appuyez pour voir sur la carte'; + + @override + String get radioSettings => 'Paramètres radio'; + + @override + String get frequencyMHz => 'Fréquence (MHz)'; + + @override + String get bandwidth => 'Bande passante'; + + @override + String get spreadingFactor => 'Facteur d\'étalement'; + + @override + String get codingRate => 'Taux de codage'; + + @override + String get txPowerDbm => 'Puissance TX (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Max : $power dBm'; + } + + @override + String get you => 'Vous'; + + @override + String exportFailed(String error) { + return 'Echec de l\'exportation : $error'; + } + + @override + String importFailed(String error) { + return 'Echec de l\'importation : $error'; + } + + @override + String get unknown => 'Inconnu'; + + @override + String get onlineLayers => 'Couches en ligne'; + + @override + String get locationTrail => 'Trace de déplacement'; + + @override + String get showTrailOnMap => 'Afficher la trace sur la carte'; + + @override + String get trailVisible => 'La trace est visible sur la carte'; + + @override + String get trailHiddenRecording => + 'La trace est masquée (enregistrement en cours)'; + + @override + String get duration => 'Durée'; + + @override + String get points => 'Points'; + + @override + String get clearTrail => 'Effacer la trace'; + + @override + String get clearTrailQuestion => 'Effacer la trace ?'; + + @override + String get clearTrailConfirmation => + 'Êtes-vous sûr de vouloir effacer la trace de déplacement actuelle ? Cette action ne peut pas être annulée.'; + + @override + String get noTrailRecorded => 'Aucune trace enregistrée pour le moment'; + + @override + String get startTrackingToRecord => + 'Démarrez le suivi de position pour enregistrer votre trace'; + + @override + String get trailControls => 'Contrôles de la trace'; + + @override + String get model => 'Modèle'; + + @override + String get version => 'Version'; + + @override + String get maxContacts => 'Contacts max'; + + @override + String get maxChannels => 'Canaux max'; + + @override + String get publicInfo => 'Informations publiques'; + + @override + String get useCurrentLocation => 'Utiliser la position actuelle'; + + @override + String get noneUnknown => 'Aucun/Inconnu'; + + @override + String get chatNode => 'Nœud de discussion'; + + @override + String get repeater => 'Répéteur'; + + @override + String get roomChannel => 'Salon/Canal'; + + @override + String typeNumber(int number) { + return 'Type $number'; + } + + @override + String failedToSave(String error) { + return 'Échec de l\'enregistrement : $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Échec de l\'obtention de la position : $error'; + } + + @override + String get sarTemplates => 'Modèles SAR'; + + @override + String get addTemplate => 'Ajouter un modèle'; + + @override + String get editTemplate => 'Modifier le modèle'; + + @override + String get deleteTemplate => 'Supprimer le modèle'; + + @override + String get templateName => 'Nom du modèle'; + + @override + String get templateNameHint => 'p. ex. Personne trouvee'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji est requis'; + + @override + String get nameRequired => 'Nom est requis'; + + @override + String get templateDescription => 'Description (facultative)'; + + @override + String get templateDescriptionHint => 'Ajoutez un contexte supplementaire...'; + + @override + String get templateColor => 'Couleur'; + + @override + String get previewFormat => 'Apercu (format de message SAR)'; + + @override + String get importFromClipboard => 'Importer'; + + @override + String get exportToClipboard => 'Exporter'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Supprimer le modele \'$name\' ?'; + } + + @override + String get templateAdded => 'Modele ajoute'; + + @override + String get templateUpdated => 'Modele mis a jour'; + + @override + String get templateDeleted => 'Modele supprime'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count modeles importes', + one: '1 modele importe', + zero: 'Aucun modele importe', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count modeles exportes vers le presse-papiers', + one: '1 modele exporte vers le presse-papiers', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Réinitialiser aux valeurs par défaut'; + + @override + String get resetToDefaultsConfirmation => + 'Cela supprimera tous les modèles personnalisés et restaurera les 4 modèles par défaut. Continuer?'; + + @override + String get reset => 'Réinitialiser'; + + @override + String get resetComplete => 'Modèles réinitialisés aux valeurs par défaut'; + + @override + String get noTemplates => 'Aucun modele disponible'; + + @override + String get tapAddToCreate => 'Appuyez sur + pour creer votre premier modele'; + + @override + String get ok => 'OK'; + + @override + String get locationPermission => 'Autorisation de localisation'; + + @override + String get checking => 'Vérification...'; + + @override + String get locationPermissionGrantedAlways => 'Accordée (Toujours)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Accordée (En cours d\'utilisation)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Refusée - Appuyez pour demander'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Refusée définitivement - Ouvrir les paramètres'; + + @override + String get locationPermissionDialogContent => + 'L\'autorisation de localisation est définitivement refusée. Veuillez l\'activer dans les paramètres de votre appareil pour utiliser le suivi GPS et le partage de localisation.'; + + @override + String get openSettings => 'Ouvrir les paramètres'; + + @override + String get locationPermissionGranted => + 'Autorisation de localisation accordée !'; + + @override + String get locationPermissionRequiredForGps => + 'L\'autorisation de localisation est nécessaire pour le suivi GPS et le partage de localisation.'; + + @override + String get locationPermissionAlreadyGranted => + 'L\'autorisation de localisation est déjà accordée.'; + + @override + String get sarNavyBlue => 'SAR Bleu Marine'; + + @override + String get sarNavyBlueDescription => 'Mode Professionnel/Opérations'; + + @override + String get selectRecipient => 'Sélectionner le destinataire'; + + @override + String get broadcastToAllNearby => 'Diffuser à tous à proximité'; + + @override + String get searchRecipients => 'Rechercher des destinataires...'; + + @override + String get noContactsFound => 'Aucun contact trouvé'; + + @override + String get noRoomsFound => 'Aucune salle trouvée'; + + @override + String get noRecipientsAvailable => 'Aucun destinataire disponible'; + + @override + String get noChannelsFound => 'Aucun canal trouvé'; + + @override + String get newMessage => 'Nouveau message'; + + @override + String get channel => 'Canal'; + + @override + String get samplePoliceLead => 'Chef de Police'; + + @override + String get sampleDroneOperator => 'Opérateur de Drone'; + + @override + String get sampleFirefighterAlpha => 'Pompier'; + + @override + String get sampleMedicCharlie => 'Médecin'; + + @override + String get sampleCommandDelta => 'Commandement'; + + @override + String get sampleFireEngine => 'Camion de Pompiers'; + + @override + String get sampleAirSupport => 'Soutien Aérien'; + + @override + String get sampleBaseCoordinator => 'Coordinateur de Base'; + + @override + String get channelEmergency => 'Urgence'; + + @override + String get channelCoordination => 'Coordination'; + + @override + String get channelUpdates => 'Mises à jour'; + + @override + String get sampleTeamMember => 'Membre d\'Équipe Exemple'; + + @override + String get sampleScout => 'Éclaireur Exemple'; + + @override + String get sampleBase => 'Base Exemple'; + + @override + String get sampleSearcher => 'Chercheur Exemple'; + + @override + String get sampleObjectBackpack => ' Sac à dos trouvé - couleur bleue'; + + @override + String get sampleObjectVehicle => + ' Véhicule abandonné - vérifier le propriétaire'; + + @override + String get sampleObjectCamping => ' Équipement de camping découvert'; + + @override + String get sampleObjectTrailMarker => ' Balise de sentier trouvée hors piste'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Toutes les équipes se signaler'; + + @override + String get sampleMsgWeatherUpdate => + 'Mise à jour météo : Ciel dégagé, temp 18°C'; + + @override + String get sampleMsgBaseCamp => + 'Camp de base établi à la zone de rassemblement'; + + @override + String get sampleMsgTeamAlpha => 'Équipe se déplaçant vers le secteur 2'; + + @override + String get sampleMsgRadioCheck => + 'Test radio - toutes les stations répondent'; + + @override + String get sampleMsgWaterSupply => + 'Approvisionnement en eau disponible au point de contrôle 3'; + + @override + String get sampleMsgTeamBravo => 'Équipe signale : secteur 1 dégagé'; + + @override + String get sampleMsgEtaRallyPoint => + 'ETA au point de ralliement : 15 minutes'; + + @override + String get sampleMsgSupplyDrop => + 'Largage de ravitaillement confirmé pour 14h00'; + + @override + String get sampleMsgDroneSurvey => + 'Surveillance par drone terminée - aucune découverte'; + + @override + String get sampleMsgTeamCharlie => 'Équipe demande du renfort'; + + @override + String get sampleMsgRadioDiscipline => + 'Toutes les unités : maintenir la discipline radio'; + + @override + String get sampleMsgUrgentMedical => + 'URGENT : Assistance médicale nécessaire au secteur 4'; + + @override + String get sampleMsgAdultMale => ' Homme adulte, conscient'; + + @override + String get sampleMsgFireSpotted => 'Feu repéré - coordonnées à venir'; + + @override + String get sampleMsgSpreadingRapidly => ' Se propage rapidement !'; + + @override + String get sampleMsgPriorityHelicopter => + 'PRIORITÉ : Besoin de soutien hélicoptère'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Équipe médicale en route vers votre position'; + + @override + String get sampleMsgEvacHelicopter => + 'Hélicoptère d\'évacuation ETA 10 minutes'; + + @override + String get sampleMsgEmergencyResolved => 'Urgence résolue - tout est clair'; + + @override + String get sampleMsgEmergencyStagingArea => + ' Zone de rassemblement d\'urgence'; + + @override + String get sampleMsgEmergencyServices => + 'Services d\'urgence notifiés et en réponse'; + + @override + String get sampleAlphaTeamLead => 'Chef d\'Équipe'; + + @override + String get sampleBravoScout => 'Éclaireur'; + + @override + String get sampleCharlieMedic => 'Médecin'; + + @override + String get sampleDeltaNavigator => 'Navigateur'; + + @override + String get sampleEchoSupport => 'Soutien'; + + @override + String get sampleBaseCommand => 'Commandement de Base'; + + @override + String get sampleFieldCoordinator => 'Coordinateur de Terrain'; + + @override + String get sampleMedicalTeam => 'Équipe Médicale'; + + @override + String get mapDrawing => 'Dessin de Carte'; + + @override + String get navigateToDrawing => 'Naviguer vers le Dessin'; + + @override + String get copyCoordinates => 'Copier les Coordonnées'; + + @override + String get hideFromMap => 'Masquer de la Carte'; + + @override + String get lineDrawing => 'Ligne'; + + @override + String get rectangleDrawing => 'Rectangle'; + + @override + String get manualCoordinates => 'Coordonnées Manuelles'; + + @override + String get enterCoordinatesManually => 'Entrer les coordonnées manuellement'; + + @override + String get latitudeLabel => 'Latitude'; + + @override + String get longitudeLabel => 'Longitude'; + + @override + String get exampleCoordinates => 'Exemple: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Partager le Dessin'; + + @override + String get shareWithAllNearbyDevices => + 'Partager avec tous les appareils à proximité'; + + @override + String get shareToRoom => 'Partager dans la Salle'; + + @override + String get sendToPersistentStorage => + 'Envoyer au stockage persistant de la salle'; + + @override + String get deleteDrawingConfirm => + 'Êtes-vous sûr de vouloir supprimer ce dessin?'; + + @override + String get drawingDeleted => 'Dessin supprimé'; + + @override + String yourDrawingsCount(int count) { + return 'Vos Dessins ($count)'; + } + + @override + String get shared => 'Partagé'; + + @override + String get line => 'Ligne'; + + @override + String get rectangle => 'Rectangle'; + + @override + String get updateAvailable => 'Mise à Jour Disponible'; + + @override + String get currentVersion => 'Actuelle'; + + @override + String get latestVersion => 'Dernière'; + + @override + String get downloadUpdate => 'Télécharger'; + + @override + String get updateLater => 'Plus Tard'; + + @override + String get cadastralParcels => 'Parcelles Cadastrales'; + + @override + String get forestRoads => 'Chemins Forestiers'; + + @override + String get wmsOverlays => 'Superpositions WMS'; + + @override + String get hikingTrails => 'Sentiers de Randonnée'; + + @override + String get mainRoads => 'Routes Principales'; + + @override + String get houseNumbers => 'Numéros de Maison'; + + @override + String get fireHazardZones => 'Zones à Risque d\'Incendie'; + + @override + String get historicalFires => 'Incendies Historiques'; + + @override + String get firebreaks => 'Coupe-feu'; + + @override + String get krasFireZones => 'Zones d\'Incendie Kras'; + + @override + String get placeNames => 'Noms de Lieux'; + + @override + String get municipalityBorders => 'Limites Municipales'; + + @override + String get topographicMap => 'Carte Topographique 1:25000'; + + @override + String get recentMessages => 'Messages Récents'; + + @override + String get addChannel => 'Ajouter un Canal'; + + @override + String get channelName => 'Nom du Canal'; + + @override + String get channelNameHint => 'par ex. Équipe de Sauvetage Alpha'; + + @override + String get channelSecret => 'Mot de Passe du Canal'; + + @override + String get channelSecretHint => 'Mot de passe partagé pour ce canal'; + + @override + String get channelSecretHelp => + 'Ce mot de passe doit être partagé avec tous les membres de l\'équipe qui ont besoin d\'accéder à ce canal'; + + @override + String get channelTypesInfo => + 'Canaux hash (#équipe) : Mot de passe généré automatiquement à partir du nom. Même nom = même canal sur tous les appareils.\n\nCanaux privés : Utilisez un mot de passe explicite. Seuls ceux qui ont le mot de passe peuvent rejoindre.'; + + @override + String get hashChannelInfo => + 'Canal hash : Le mot de passe sera automatiquement généré à partir du nom du canal. Toute personne utilisant le même nom rejoindra le même canal.'; + + @override + String get channelNameRequired => 'Le nom du canal est requis'; + + @override + String get channelNameTooLong => + 'Le nom du canal doit contenir 31 caractères ou moins'; + + @override + String get channelSecretRequired => 'Le mot de passe du canal est requis'; + + @override + String get channelSecretTooLong => + 'Le mot de passe du canal doit contenir 32 caractères ou moins'; + + @override + String get invalidAsciiCharacters => + 'Seuls les caractères ASCII sont autorisés'; + + @override + String get channelCreatedSuccessfully => 'Canal créé avec succès'; + + @override + String channelCreationFailed(String error) { + return 'Échec de la création du canal : $error'; + } + + @override + String get deleteChannel => 'Supprimer le Canal'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Êtes-vous sûr de vouloir supprimer le canal \"$channelName\" ? Cette action ne peut pas être annulée.'; + } + + @override + String get channelDeletedSuccessfully => 'Canal supprimé avec succès'; + + @override + String channelDeletionFailed(String error) { + return 'Échec de la suppression du canal : $error'; + } + + @override + String get createChannel => 'Créer un Canal'; + + @override + String get wizardBack => 'Retour'; + + @override + String get wizardSkip => 'Passer'; + + @override + String get wizardNext => 'Suivant'; + + @override + String get wizardGetStarted => 'Commencer'; + + @override + String get wizardWelcomeTitle => 'Bienvenue dans MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Voir le tutoriel de bienvenue'; + + @override + String get allTeamContacts => 'Tous les contacts de l\'équipe'; + + @override + String directMessagesInfo(int count) { + return 'Messages directs avec confirmations. Envoyé à $count membres de l\'équipe.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'Marqueur SAR envoyé à $count contacts'; + } + + @override + String get noContactsAvailable => 'Aucun contact d\'équipe disponible'; + + @override + String get reply => 'Répondre'; + + @override + String get technicalDetails => 'Détails techniques'; + + @override + String get messageTechnicalDetails => 'Détails techniques du message'; + + @override + String get linkQuality => 'Qualité du lien'; + + @override + String get delivery => 'Livraison'; + + @override + String get status => 'Statut'; + + @override + String get expectedAckTag => 'Balise ACK attendue'; + + @override + String get roundTrip => 'Aller-retour'; + + @override + String get retryAttempt => 'Tentative de renvoi'; + + @override + String get floodFallback => 'Repli inondation'; + + @override + String get identity => 'Identité'; + + @override + String get messageId => 'ID du message'; + + @override + String get sender => 'Expéditeur'; + + @override + String get senderKey => 'Clé de l\'expéditeur'; + + @override + String get recipient => 'Destinataire'; + + @override + String get recipientKey => 'Clé du destinataire'; + + @override + String get voice => 'Voix'; + + @override + String get voiceId => 'ID vocal'; + + @override + String get envelope => 'Enveloppe'; + + @override + String get sessionProgress => 'Progression de session'; + + @override + String get complete => 'Complet'; + + @override + String get rawDump => 'Vidage brut'; + + @override + String get cannotRetryMissingRecipient => + 'Impossible de réessayer : informations du destinataire manquantes'; + + @override + String get voiceUnavailable => 'Voix indisponible pour le moment'; + + @override + String get requestingVoice => 'Demande de voix'; + + @override + String get device => 'appareil'; + + @override + String get change => 'Modifier'; + + @override + String get wizardOverviewDescription => + 'Cette application réunit la messagerie MeshCore, les mises à jour SAR sur le terrain, la cartographie et les outils de l\'appareil au même endroit.'; + + @override + String get wizardOverviewFeature1 => + 'Envoyez des messages directs, des publications de salon et des messages de canal depuis l\'onglet principal Messages.'; + + @override + String get wizardOverviewFeature2 => + 'Partagez des marqueurs SAR, des dessins de carte, des extraits vocaux et des images sur le réseau maillé.'; + + @override + String get wizardOverviewFeature3 => + 'Connectez-vous en BLE ou en TCP, puis gérez la radio compagnon directement dans l\'application.'; + + @override + String get wizardMessagingTitle => 'Messagerie et rapports de terrain'; + + @override + String get wizardMessagingDescription => + 'Ici, les messages sont plus que du texte brut. L\'application prend déjà en charge plusieurs charges opérationnelles et flux de transfert.'; + + @override + String get wizardMessagingFeature1 => + 'Envoyez des messages directs, des publications de salon et du trafic de canal depuis un seul compositeur.'; + + @override + String get wizardMessagingFeature2 => + 'Créez des mises à jour SAR et des modèles SAR réutilisables pour les rapports de terrain courants.'; + + @override + String get wizardMessagingFeature3 => + 'Transférez des sessions vocales et des images avec la progression et les estimations de temps d\'antenne dans l\'interface.'; + + @override + String get wizardConnectDeviceTitle => 'Connecter l\'appareil'; + + @override + String get wizardConnectDeviceDescription => + 'Connectez votre radio MeshCore, choisissez un nom et appliquez un préréglage radio avant de continuer.'; + + @override + String get wizardSetupBadge => 'Configuration'; + + @override + String get wizardOverviewBadge => 'Aperçu'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Connecté à $deviceName'; + } + + @override + String get wizardNoDeviceConnected => + 'Aucun appareil connecté pour le moment'; + + @override + String get wizardSkipForNow => 'Ignorer pour l\'instant'; + + @override + String get wizardDeviceNameLabel => 'Nom de l\'appareil'; + + @override + String get wizardDeviceNameHelp => + 'Ce nom est annoncé aux autres utilisateurs de MeshCore.'; + + @override + String get wizardConfigRegionLabel => 'Région de configuration'; + + @override + String get wizardConfigRegionHelp => + 'Utilise la liste officielle complète des préréglages MeshCore. La valeur par défaut est EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Assurez-vous que le préréglage sélectionné correspond à votre réglementation radio locale.'; + + @override + String get wizardPresetNote2 => + 'La liste correspond au flux officiel des préréglages de l\'outil de configuration MeshCore.'; + + @override + String get wizardPresetNote3 => + 'EU/UK (Narrow) reste sélectionné par défaut pendant l\'onboarding.'; + + @override + String get wizardSaving => 'Enregistrement...'; + + @override + String get wizardSaveAndContinue => 'Enregistrer et continuer'; + + @override + String get wizardEnterDeviceName => + 'Saisissez un nom d\'appareil avant de continuer.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return '$deviceName enregistré avec $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Contacts, salons et répéteurs'; + + @override + String get wizardNetworkDescription => + 'L\'onglet Contacts organise le réseau que vous découvrez et les routes apprises au fil du temps.'; + + @override + String get wizardNetworkFeature1 => + 'Consultez les membres de l\'équipe, les répéteurs, les salons, les canaux et les annonces en attente dans une seule liste.'; + + @override + String get wizardNetworkFeature2 => + 'Utilisez le smart ping, la connexion aux salons, les chemins appris et les outils de réinitialisation des routes lorsque la connectivité devient chaotique.'; + + @override + String get wizardNetworkFeature3 => + 'Créez des canaux et gérez les destinations réseau sans quitter l\'application.'; + + @override + String get wizardMapOpsTitle => 'Carte, traces et géométrie partagée'; + + @override + String get wizardMapOpsDescription => + 'La carte de l\'application est directement liée à la messagerie, au suivi et aux couches SAR au lieu d\'être un simple visualiseur séparé.'; + + @override + String get wizardMapOpsFeature1 => + 'Suivez votre position, celles de vos coéquipiers et les traces de déplacement sur la carte.'; + + @override + String get wizardMapOpsFeature2 => + 'Ouvrez les dessins depuis les messages, prévisualisez-les en ligne et supprimez-les de la carte si nécessaire.'; + + @override + String get wizardMapOpsFeature3 => + 'Utilisez les vues de carte des répéteurs et les couches partagées pour comprendre la portée du réseau sur le terrain.'; + + @override + String get wizardToolsTitle => 'Outils au-delà de la messagerie'; + + @override + String get wizardToolsDescription => + 'Il y a plus ici que les quatre onglets principaux. L\'application inclut aussi la configuration, le diagnostic et des workflows de capteurs optionnels.'; + + @override + String get wizardToolsFeature1 => + 'Ouvrez la configuration de l\'appareil pour modifier les réglages radio, la télémétrie, la puissance TX et les détails du compagnon.'; + + @override + String get wizardToolsFeature2 => + 'Activez l\'onglet Capteurs lorsque vous voulez des tableaux de bord surveillés et des actions d\'actualisation rapide.'; + + @override + String get wizardToolsFeature3 => + 'Utilisez les journaux de paquets, le balayage du spectre et les diagnostics développeur pour dépanner le réseau maillé.'; + + @override + String get postConnectDiscoveryTitle => 'Découvrir les relais maintenant ?'; + + @override + String get 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.'; + + @override + String get contactInSensors => 'Dans Capteurs'; + + @override + String get contactAddToSensors => 'Ajouter aux capteurs'; + + @override + String get contactSetPath => 'Définir le chemin'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName ajouté aux capteurs'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Échec de l\'effacement de la route : $error'; + } + + @override + String get contactRouteCleared => 'Route effacée'; + + @override + String contactRouteSet(String route) { + return 'Route définie : $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Échec du réglage de la route : $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'Délai ACK expiré'; + + @override + String get opcode => 'Opcode'; + + @override + String get payload => 'Charge utile'; + + @override + String get hops => 'Sauts'; + + @override + String get hashSize => 'Taille du hash'; + + @override + String get pathBytes => 'Octets du chemin'; + + @override + String get selectedPath => 'Chemin sélectionné'; + + @override + String get estimatedTx => 'Émission estimée'; + + @override + String get senderToReceipt => 'Émetteur au reçu'; + + @override + String get receivedCopies => 'Copies reçues'; + + @override + String get retryCause => 'Cause de la relance'; + + @override + String get retryMode => 'Mode de relance'; + + @override + String get retryResult => 'Résultat de la relance'; + + @override + String get lastRetry => 'Dernière relance'; + + @override + String get rxPackets => 'Paquets RX'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Débit'; + + @override + String get window => 'Fenêtre'; + + @override + String get posttxDelay => 'Délai post-émission'; + + @override + String get bandpass => 'Passe-bande'; + + @override + String get bandpassFilterVoice => 'Filtre passe-bande voix'; + + @override + String get active => 'Actif'; + + @override + String get addContact => 'Ajouter un contact'; + + @override + String get all => 'Tout'; + + @override + String get clearAllLabel => 'Tout effacer'; + + @override + String get clearFilters => 'Effacer les filtres'; + + @override + String get clearRoute => 'Effacer la route'; + + @override + String get clearMessages => 'Effacer les messages'; + + @override + String get clearScale => 'Effacer l\'échelle'; + + @override + String get clearDiscoveries => 'Effacer les découvertes'; + + @override + String get clearOnlineTraceDatabase => 'Effacer la base de traces'; + + @override + String get clearAllChannels => 'Effacer tous les canaux'; + + @override + String get clearAllContacts => 'Effacer tous les contacts'; + + @override + String get clearChannels => 'Effacer les canaux'; + + @override + String get clearContacts => 'Effacer les contacts'; + + @override + String get clearPathOnMaxRetry => 'Effacer le chemin au max de relances'; + + @override + String get create => 'Créer'; + + @override + String get custom => 'Personnalisé'; + + @override + String get defaultValue => 'Par défaut'; + + @override + String get duplicate => 'Dupliquer'; + + @override + String get editName => 'Modifier le nom'; + + @override + String get open => 'Ouvrir'; + + @override + String get paste => 'Coller'; + + @override + String get preview => 'Aperçu'; + + @override + String get remove => 'Supprimer'; + + @override + String get rename => 'Renommer'; + + @override + String get resolveAll => 'Résoudre tout'; + + @override + String get send => 'Envoyer'; + + @override + String get sendAnyway => 'Envoyer quand même'; + + @override + String get share => 'Partager'; + + @override + String get trace => 'Trace'; + + @override + String get discovery => 'Découverte'; + + @override + String get discoverRepeaters => 'Découvrir les répéteurs'; + + @override + String get discoverSensors => 'Découvrir les capteurs'; + + @override + String get repeaterDiscoverySent => 'Découverte de répéteurs envoyée'; + + @override + String get sensorDiscoverySent => 'Découverte de capteurs envoyée'; + + @override + String get clearedPendingDiscoveries => 'Découvertes en attente effacées.'; + + @override + String get autoDiscovery => 'Découverte automatique'; + + @override + String get enableAutomaticAdding => 'Activer l\'ajout automatique'; + + @override + String get autoaddRepeaters => 'Ajout auto des répéteurs'; + + @override + String get autoaddRoomServers => 'Ajout auto des serveurs de salle'; + + @override + String get autoaddSensors => 'Ajout auto des capteurs'; + + @override + String get autoaddUsers => 'Ajout auto des utilisateurs'; + + @override + String get overwriteOldestWhenFull => 'Écraser les plus anciens si plein'; + + @override + String get storage => 'Stockage'; + + @override + String get dangerZone => 'Zone de danger'; + + @override + String get profiles => 'Profils'; + + @override + String get favourites => 'Favoris'; + + @override + String get sensors => 'Capteurs'; + + @override + String get others => 'Autres'; + + @override + String get gpsModule => 'Module GPS'; + + @override + String get liveTraffic => 'Trafic en direct'; + + @override + String get repeatersMap => 'Carte des répéteurs'; + + @override + String get blePacketLogs => 'Journaux de paquets BLE'; + + @override + String get onlineTraceDatabase => 'Base de données de traces'; + + @override + String get routePathByteSize => 'Taille en octets du chemin'; + + @override + String get messageNotifications => 'Notifications de messages'; + + @override + String get sarAlerts => 'Alertes SAR'; + + @override + String get discoveryNotifications => 'Notifications de découverte'; + + @override + String get updateNotifications => 'Notifications de mise à jour'; + + @override + String get muteWhileAppIsOpen => 'Muet quand l\'appli est ouverte'; + + @override + String get disableContacts => 'Désactiver les contacts'; + + @override + String get enableSensorsTab => 'Activer l\'onglet Capteurs'; + + @override + String get enableProfiles => 'Activer les profils'; + + @override + String get nearestRepeaterFallback => 'Répéteur le plus proche en repli'; + + @override + String get deleteAllStoredMessageHistory => 'Supprimer tout l\'historique'; + + @override + String get messageFontSize => 'Taille de police des messages'; + + @override + String get rotateMapWithHeading => 'Tourner la carte avec le cap'; + + @override + String get showMapDebugInfo => 'Afficher les infos de débogage'; + + @override + String get openMapInFullscreen => 'Ouvrir la carte en plein écran'; + + @override + String get showSarMarkersLabel => 'Afficher les marqueurs SAR'; + + @override + String get hideRepeatersOnMap => 'Masquer les répéteurs sur la carte'; + + @override + String get setMapScale => 'Définir l\'échelle de la carte'; + + @override + String get customMapScaleSaved => + 'Échelle de carte personnalisée enregistrée'; + + @override + String get voiceBitrate => 'Débit vocal'; + + @override + String get voiceCompressor => 'Compresseur vocal'; + + @override + String get voiceLimiter => 'Limiteur vocal'; + + @override + String get micAutoGain => 'Gain auto du micro'; + + @override + String get echoCancellation => 'Annulation d\'écho'; + + @override + String get noiseSuppression => 'Suppression du bruit'; + + @override + String get trimSilenceInVoiceMessages => + 'Couper le silence dans les messages vocaux'; + + @override + String get compressor => 'Compresseur'; + + @override + String get limiter => 'Limiteur'; + + @override + String get autoGain => 'Gain automatique'; + + @override + String get echoCancel => 'Écho'; + + @override + String get noiseSuppress => 'Bruit'; + + @override + String get silenceTrim => 'Silence'; + + @override + String get maxImageSize => 'Taille d\'image maximale'; + + @override + String get imageCompression => 'Compression d\'image'; + + @override + String get grayscale => 'Niveaux de gris'; + + @override + String get ultraMode => 'Mode ultra'; + + @override + String get fastPrivateGpsUpdates => 'Mises à jour GPS privées rapides'; + + @override + String get movementThreshold => 'Seuil de mouvement'; + + @override + String get fastGpsMovementThreshold => 'Seuil de mouvement GPS rapide'; + + @override + String get fastGpsActiveuseInterval => + 'Intervalle d\'utilisation active GPS rapide'; + + @override + String get activeuseUpdateInterval => + 'Intervalle de mise à jour en utilisation active'; + + @override + String get repeatNearbyTraffic => 'Répéter le trafic proche'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Relayer via les répéteurs du réseau'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Proximité seulement, sans inondation'; + + @override + String get multihop => 'Multi-saut'; + + @override + String get createProfile => 'Créer un profil'; + + @override + String get renameProfile => 'Renommer le profil'; + + @override + String get newProfile => 'Nouveau profil'; + + @override + String get manageProfiles => 'Gérer les profils'; + + @override + String get enableProfilesToStartManagingThem => + 'Activez les profils pour commencer à les gérer.'; + + @override + String get openMessage => 'Ouvrir le message'; + + @override + String get jumpToTheRelatedSarMessage => 'Aller au message SAR associé'; + + @override + String get removeSarMarker => 'Supprimer le marqueur SAR'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Sélectionnez une destination pour envoyer le marqueur'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'Marqueur SAR diffusé sur le canal public'; + + @override + String get sarMarkerSentToRoom => 'Marqueur SAR envoyé à la salle'; + + @override + String get loadFromGallery => 'Charger depuis la galerie'; + + @override + String get replaceImage => 'Remplacer l\'image'; + + @override + String get selectFromGallery => 'Sélectionner depuis la galerie'; + + @override + String get team => 'Équipe'; + + @override + String get found => 'Trouvé'; + + @override + String get staging => 'Zone de regroupement'; + + @override + String get object => 'Objet'; + + @override + String get quiet => 'Calme'; + + @override + String get busy => 'Occupé'; + + @override + String get searchMessages => 'Rechercher des messages'; + + @override + String get sendImageFromGallery => 'Envoyer une image de la galerie'; + + @override + String get takePhoto => 'Prendre une photo'; + + @override + String get allMessages => 'Tous les messages'; + + @override + String get sendToPublicChannel => 'Envoyer au canal public ?'; + + @override + String get selectMarkerTypeAndDestination => + 'Sélectionnez le type de marqueur et la destination'; + + @override + String get noDestinationsAvailableLabel => 'Aucune destination disponible'; + + @override + String get image => 'Image'; + + @override + String get format => 'Format'; + + @override + String get dimensions => 'Dimensions'; + + @override + String get segments => 'Segments'; + + @override + String get transfers => 'Transferts'; + + @override + String get downloadedBy => 'Téléchargé par'; + + @override + String get saveDiscoverySettings => + 'Enregistrer les paramètres de découverte'; + + @override + String get savePublicInfo => 'Enregistrer les infos publiques'; + + @override + String get saveRadioSettings => 'Enregistrer les paramètres radio'; + + @override + String get savePath => 'Enregistrer le chemin'; + + @override + String get wipeDeviceData => 'Effacer les données de l\'appareil'; + + @override + String get wipeDevice => 'Effacer l\'appareil'; + + @override + String get destructiveDeviceActions => 'Actions destructives de l\'appareil.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Choisissez un préréglage ou ajustez les paramètres radio.'; + + @override + String get used => 'Utilisé'; + + @override + String get total => 'Total'; + + @override + String get renameValue => 'Renommer la valeur'; + + @override + String get customizeFields => 'Personnaliser les champs'; + + @override + String get livePreview => 'Aperçu en direct'; + + @override + String get refreshSchedule => 'Planification d\'actualisation'; + + @override + String get noResponse => 'Pas de réponse'; + + @override + String get refreshing => 'Actualisation'; + + @override + String get unavailable => 'Indisponible'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Choisissez un relais ou nœud à surveiller.'; + + @override + String get publicKeyLabel => 'Clé publique'; + + @override + String get alreadyInContacts => 'Déjà dans les contacts'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Connectez-vous à un appareil avant d\'ajouter des contacts'; + + @override + String get fromContacts => 'Des contacts'; + + @override + String get onlineOnly => 'En ligne uniquement'; + + @override + String get inBoth => 'Dans les deux'; + + @override + String get source => 'Source'; + + @override + String get allMessagesCleared => 'Tous les messages effacés'; + + @override + String get onlineTraceDatabaseCleared => 'Base de données de traces effacée'; + + @override + String get packetLogsCleared => 'Journaux de paquets effacés'; + + @override + String get hexDataCopiedToClipboard => + 'Données hex copiées dans le presse-papiers'; + + @override + String get developerModeEnabled => 'Mode développeur activé'; + + @override + String get developerModeDisabled => 'Mode développeur désactivé'; + + @override + String get clipboardIsEmpty => 'Le presse-papiers est vide'; + + @override + String get contactImported => 'Contact importé'; + + @override + String get contactLinkCopiedToClipboard => + 'Lien du contact copié dans le presse-papiers'; + + @override + String get failedToExportContact => 'Échec de l\'exportation du contact'; + + @override + String get noLogsToExport => 'Aucun journal à exporter'; + + @override + String get exportAsCsv => 'Exporter en CSV'; + + @override + String get exportAsText => 'Exporter en texte'; + + @override + String get receivedRfc3339 => 'Reçu (RFC3339)'; + + @override + String get buildTime => 'Heure de build'; + + @override + String get downloadUrlNotAvailable => 'URL de téléchargement non disponible'; + + @override + String get cannotOpenDownloadUrl => + 'Impossible d\'ouvrir l\'URL de téléchargement'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'La vérification des mises à jour n\'est disponible que sur Android'; + + @override + String get youAreRunningTheLatestVersion => + 'Vous utilisez la dernière version'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Mise à jour disponible mais URL de téléchargement introuvable'; + + @override + String get startTictactoe => 'Démarrer Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe indisponible'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: adversaire inconnu'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: en attente du début'; + + @override + String get acceptsShareLinks => 'Accepte les liens partagés'; + + @override + String get supportsRawHex => 'Supporte le hex brut'; + + @override + String get clipboardfriendly => 'Compatible avec le presse-papiers'; + + @override + String get captured => 'Capturé'; + + @override + String get size => 'Taille'; + + @override + String get noCustomChannelsToClear => 'Aucun canal personnalisé à effacer.'; + + @override + String get noDeviceContactsToClear => + 'Aucun contact de l\'appareil à effacer.'; + + @override + String get aToZ => 'A-Z'; + + @override + String get add => 'Ajouter'; + + @override + String get addAdditionalDetails => 'Ajouter des détails supplémentaires'; + + @override + String get addPeer => 'Ajouter un pair'; + + @override + String get addPeerManually => 'Ajouter un pair manuellement'; + + @override + String addedToContacts(String name) { + return '$name ajouté aux contacts'; + } + + @override + String addedToSensors(String name) { + return '$name ajouté aux capteurs'; + } + + @override + String get allowAll => 'Tout autoriser'; + + @override + String get anonymousRxStats => 'Statistiques RX anonymes'; + + @override + String get appearance => 'Apparence'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Télémétrie de base'; + + @override + String get blePin => 'PIN BLE'; + + @override + String get buzzerAlerts => 'Alertes sonores'; + + @override + String get cancelImageReceive => 'Annuler la réception d\'image'; + + @override + String get choose => 'Choisir'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Choisir les onglets et sections de contacts à afficher'; + + @override + String get clearCache => 'Vider le cache'; + + @override + String get clearHistory => 'Effacer l\'historique'; + + @override + String get clearLiveView => 'Effacer la vue en direct'; + + @override + String get clearLogs => 'Effacer les journaux'; + + @override + String get clearOfflineCache => 'Vider le cache hors ligne ?'; + + @override + String get clearOverlay => 'Effacer la superposition'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Effacer la route uniquement après l\'échec de toutes les tentatives'; + + @override + String get codecBitrateAndAudioProcessing => + 'Codec, débit et traitement audio'; + + @override + String get connectByIpAddress => 'Connexion par adresse IP'; + + @override + String get controlWhichAlertsYouReceive => + 'Contrôler les alertes que vous recevez'; + + @override + String get copyFullHex => 'Copier le Hex complet'; + + @override + String get copyRawResponse => 'Copier la réponse brute'; + + @override + String get couldNotLoadMetHistory => + 'Impossible de charger l\'historique MET'; + + @override + String customizeSensor(String name) { + return 'Personnaliser $name'; + } + + @override + String get data => 'Données'; + + @override + String get deleteGroup => 'Supprimer le groupe'; + + @override + String deleteStyleConfirm(String name) { + return 'Supprimer $name ?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Supprimer ce $type ?'; + } + + @override + String get deny => 'Refuser'; + + @override + String get deviceInfo => 'Infos de l\'appareil'; + + @override + String get deviceSettings => 'Paramètres de l\'appareil'; + + @override + String get deviceTimeSynced => + 'Heure de l\'appareil synchronisée avec ce téléphone.'; + + @override + String get disableFastGpsPublishing => 'Désactiver la publication GPS rapide'; + + @override + String get displayMarkersAndTraceDatabase => + 'Affichage, marqueurs et base de données de traces'; + + @override + String get download => 'Télécharger'; + + @override + String get environmentalTelemetry => 'Télémétrie environnementale'; + + @override + String errorCheckingForUpdates(String error) { + return 'Erreur lors de la vérification des mises à jour : $error'; + } + + @override + String errorGeneric(String error) { + return 'Erreur : $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Erreur lors de l\'ouverture du téléchargement : $error'; + } + + @override + String get exportLogs => 'Exporter les journaux'; + + @override + String get extraAggressiveAvifCompression => + 'Compression AVIF extra-agressive'; + + @override + String failedToAddContact(String error) { + return 'Échec de l\'ajout du contact : $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Échec de l\'ajout de $name : $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Échec de l\'effacement des canaux : $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Échec de l\'effacement des contacts : $error'; + } + + @override + String get failedToConnectViaSerial => 'Échec de la connexion via série'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Échec du chargement de l\'aperçu : $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Échec du chargement de la trace : $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Échec de l\'envoi du marqueur SAR : $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Échec du réglage du mode buzzer : $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Échec du réglage du mode GPS : $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Échec de la synchronisation de l\'heure de l\'appareil : $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Échec de l\'effacement des données de l\'appareil : $error'; + } + + @override + String get fastGpsTargetChannel => 'Canal cible GPS rapide'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Mises à jour rapides, seuils et permissions'; + + @override + String get fetchingCatalog => 'Récupération du catalogue...'; + + @override + String get fifteenMinutes => '15 minutes'; + + @override + String get fifteenSeconds => '15 secondes'; + + @override + String get filterByDirection => 'Filtrer par direction'; + + @override + String get finish => 'Terminer'; + + @override + String get fiveMinutes => '5 minutes'; + + @override + String get fiveSeconds => '5 secondes'; + + @override + String get fix => 'Correctif'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'Politique d\'annonce GPS'; + + @override + String get gpsAndLocation => 'GPS et localisation'; + + @override + String get gpsDiagnostics => 'Diagnostics GPS'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Aide'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Masqué'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Comment la radio ajoute automatiquement les nœuds découverts.'; + + @override + String get images => 'Images'; + + @override + String get importProfile => 'Importer un profil'; + + @override + String get lastFix => 'Dernier correctif'; + + @override + String get latest => 'Le plus récent'; + + @override + String get latitude => 'Latitude'; + + @override + String get loadASavedRegion => 'Charger une région enregistrée'; + + @override + String get loading => 'Chargement'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Partage de position, matériel et intervalle de mise à jour.'; + + @override + String get locationTelemetry => 'Télémétrie de position'; + + @override + String get lockDestination => 'Verrouiller la destination'; + + @override + String get longitude => 'Longitude'; + + @override + String get mapStyle => 'Style de carte'; + + @override + String get max => 'Max'; + + @override + String get maxZoom => 'Zoom max'; + + @override + String get messaging => 'Messagerie'; + + @override + String get metHistory => 'Historique MET'; + + @override + String get min => 'Min'; + + @override + String get minZoom => 'Zoom min'; + + @override + String get moveDown => 'Déplacer vers le bas'; + + @override + String get moveUp => 'Déplacer vers le haut'; + + @override + String get multiAckMode => 'Mode multi-ACK'; + + @override + String get multiDeviceWorkspaceManagement => + 'Gestion d\'espace de travail multi-appareils'; + + @override + String get myLocation => 'Ma position'; + + @override + String get nameAndTelemetryShared => + 'Nom et télémétrie partagés avec d\'autres appareils.'; + + @override + String newVersionAvailable(String version) { + return 'Nouvelle version : $version'; + } + + @override + String get newer => 'Plus récent'; + + @override + String get noNeighboursFound => 'Aucun voisin trouvé'; + + @override + String get noServersFound => 'Aucun serveur trouvé'; + + @override + String get notSet => 'Non défini'; + + @override + String get notifications => 'Notifications'; + + @override + String get offZeroSeconds => 'Désactivé (0 s)'; + + @override + String get offlineMaps => 'Cartes hors ligne'; + + @override + String get older => 'Plus ancien'; + + @override + String get oneByteMode0 => '1 octet (mode 0)'; + + @override + String get oneHour => '1 heure'; + + @override + String get oneMinute => '1 minute'; + + @override + String get openPacketLogs => 'Ouvrir les journaux de paquets'; + + @override + String get packetTypeHelp => 'Aide sur les types de paquets'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Coller un lien de partage ou une annonce hex'; + + @override + String get pathHash => 'Hash du chemin'; + + @override + String get pathSize => 'Taille du chemin'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Polygone'; + + @override + String get rawResponseCopied => 'Réponse brute copiée'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Renvoyer via le répéteur le plus proche en cas d\'échec'; + + @override + String get resolveContact => 'Résoudre le contact'; + + @override + String get routingRetriesAndDestinationLock => + 'Routage, tentatives et verrouillage de destination'; + + @override + String get samples => 'Échantillons'; + + @override + String get satellites => 'Satellites'; + + @override + String get saving => 'Enregistrement...'; + + @override + String get search => 'Rechercher'; + + @override + String get searchByNameOrToken => 'Rechercher par nom ou jeton'; + + @override + String get searchDiscoveredNodes => 'Rechercher les nœuds découverts'; + + @override + String get searchInCurrentFilter => 'Rechercher dans le filtre actuel'; + + @override + String get searchLogs => 'Rechercher dans les journaux...'; + + @override + String get searchSensors => 'Rechercher les capteurs'; + + @override + String get searchThisSection => 'Rechercher dans cette section'; + + @override + String get selectedRelays => 'Relais sélectionnés'; + + @override + String get sensor => 'Capteur'; + + @override + String serialError(String error) { + return 'Erreur série : $error'; + } + + @override + String setPathForContact(String name) { + return 'Définir le chemin pour $name'; + } + + @override + String get shareLiveGps => 'Partager le GPS en direct'; + + @override + String get shareMyTiles => 'Partager mes tuiles'; + + @override + String get sharingLocationFromPhone => + 'Partage de la position sur ce canal depuis le téléphone.'; + + @override + String get sharingLocationFromRadio => + 'Partage de la position sur ce canal depuis la radio.'; + + @override + String signalDbm(String rssi) { + return 'Signal $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => 'Taille, compression et aperçu'; + + @override + String get simpleMode => 'Mode simple'; + + @override + String get simpleModeDescription => + 'Uniquement le chat et la carte avec les contacts favoris'; + + @override + String get simpleModeRequiresLockedDestination => + 'Activez d\'abord « Verrouiller la destination » avec un canal pour utiliser le mode simple'; + + @override + String get noFavouriteContacts => 'Aucun contact favori'; + + @override + String get skip => 'Passer'; + + @override + String get smallerFileSize => 'Taille de fichier réduite'; + + @override + String get sort => 'Trier'; + + @override + String get stoppedSharingLocation => + 'Arrêt du partage de position sur ce canal.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Supprimer les notifications en premier plan'; + + @override + String get tabsAndNavigation => 'Onglets et navigation'; + + @override + String get templatesAndTutorials => 'Modèles et tutoriels'; + + @override + String get tenMinutes => '10 minutes'; + + @override + String get tenSeconds => '10 secondes'; + + @override + String get testSendUpdate => 'Tester l\'envoi de mise à jour'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Thème, langue et préférences d\'affichage'; + + @override + String get thirtyMinutes => '30 minutes'; + + @override + String get thirtySeconds => '30 secondes'; + + @override + String get threeBytesMode2 => '3 octets (mode 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Statistiques de trafic, historique des messages et outils de développement'; + + @override + String get twoBytesMode1 => '2 octets (mode 1)'; + + @override + String get twoMinutes => '2 minutes'; + + @override + String get undo => 'Annuler'; + + @override + String get updated => 'Mis à jour'; + + @override + String get useContactFlags => 'Utiliser les drapeaux de contact'; + + @override + String get useSavedCoordinates => 'Utiliser les coordonnées enregistrées'; + + @override + String get view => 'Voir'; + + @override + String get viewNeighbours => 'Voir les voisins'; + + @override + String get viewPublicStats => 'Voir les statistiques publiques'; + + @override + String get setRegionScope => 'Définir la portée régionale'; + + @override + String get regionScope => 'Portée régionale'; + + @override + String get regionScopeNone => 'Aucune (globale)'; + + @override + String get clearRegionScope => 'Effacer la portée'; + + @override + String get regionScopeWarning => + 'Seuls les relais autorisant cette région retransmettront.'; + + @override + String get discoverRegions => 'Découvrir depuis les relais'; + + @override + String get discoveringRegions => 'Découverte des régions...'; + + @override + String get enterRegionName => 'Nom de région (ex. auckland)'; + + @override + String get noRegionsFound => 'Aucune région trouvée sur ce relais.'; + + @override + String regionScopeSet(String name) { + return 'Portée régionale définie sur $name'; + } + + @override + String get regionScopeCleared => 'Portée régionale effacée'; + + @override + String get selectDestinationFirst => + 'Sélectionnez d\'abord un canal, un contact ou un salon'; + + @override + String get recipientNoLongerAvailable => + 'Le destinataire n\'est plus disponible'; + + @override + String failedToSendMessage(String error) { + return 'Échec de l\'envoi : $error'; + } + + @override + String get tictactoeDirectOnly => + 'Le morpion ne fonctionne qu\'en messages directs. Choisissez d\'abord un contact.'; + + @override + String get deviceKeyUnavailable => + 'La clé de l\'appareil n\'est pas disponible'; + + @override + String get imageCompressionFailed => 'Échec de la compression de l\'image'; + + @override + String get imageFragmentationFailed => + 'Échec de la fragmentation de l\'image'; + + @override + String get failedToAnnounceImage => 'Échec de l\'annonce de l\'image'; + + @override + String get noRecipientSelected => 'Aucun destinataire sélectionné'; + + @override + String get imageSendFailed => 'Échec de l\'envoi de l\'image'; + + @override + String get microphonePermissionRequiredForVoice => + 'L\'autorisation du microphone est requise pour la voix'; + + @override + String get recordingDiscarded => 'Enregistrement abandonné'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Vous êtes sur le point d\'envoyer $mediaType sur le canal public. Ce n\'est pas conseillé, car tout le monde sur le réseau mesh peut le recevoir. Choisissez un canal privé ou étiqueté, sauf si c\'est bien votre intention.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Sélectionnez un canal privé pour partager votre position.'; + + @override + String get stopSharingMyLocation => 'Arrêter de partager ma position'; + + @override + String get shareMyLocation => 'Partager ma position'; + + @override + String get stopRecording => 'Arrêter l\'enregistrement'; + + @override + String get recordVoice => 'Enregistrer un message vocal'; + + @override + String get moreActions => 'Plus d\'actions'; + + @override + String get noMessagesInFilter => 'Aucun message dans ce filtre'; + + @override + String get noMatchesInFilter => 'Aucun résultat dans ce filtre'; + + @override + String syncFailed(String error) { + return 'Échec de la synchronisation : $error'; + } + + @override + String get invalidLink => 'Lien invalide'; + + @override + String get cannotOpenLink => 'Impossible d\'ouvrir le lien'; + + @override + String get failedToOpenLink => 'Échec de l\'ouverture du lien'; + + @override + String get failedToResendMessage => 'Échec du renvoi du message'; + + @override + String retryFailed(String error) { + return 'Échec de la nouvelle tentative : $error'; + } + + @override + String get deleteDrawingAndMessage => 'Supprimer le dessin et le message'; + + @override + String get lastEchoRelay => 'Dernier écho : relais'; + + @override + String get lastEchoPath => 'Dernier écho : chemin'; + + @override + String get lastEchoBytesReport => 'Dernier écho : rapport d\'octets'; + + @override + String get noGpsCoordinatesAvailable => 'Aucune coordonnée GPS disponible'; + + @override + String get notASarMarker => 'Ce n\'est pas un marqueur SAR'; + + @override + String failedToSaveTemplate(String error) { + return 'Échec de l\'enregistrement du modèle : $error'; + } + + @override + String get drawingNotFound => 'Dessin introuvable'; + + @override + String get unknownDrawingType => 'Type de dessin inconnu'; + + @override + String get customMapMarker => 'Marqueur de carte personnalisé'; + + @override + String customMapPointLabel(String point) { + return 'Point : $point'; + } + + @override + String mapIdLabel(String id) { + return 'ID de carte : $id'; + } + + @override + String get cannotFetchVoice => 'Impossible de récupérer la voix'; + + @override + String get senderContactUnknown => + 'Le contact de l\'expéditeur est inconnu. Synchronisez d\'abord les contacts.'; + + @override + String get senderRouteUnknown => + 'La route de l\'expéditeur est inconnue. Synchronisez d\'abord les contacts/le chemin.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Le message est trop loin ($hops sauts, max. $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'La route de l\'expéditeur n\'a pas répondu à la vérification du chemin. Synchronisez les contacts/le chemin et réessayez.'; + + @override + String get senderRouteNoRawResponse => + 'La route de l\'expéditeur n\'a pas répondu sur le chemin de transport brut.'; + + @override + String voiceFetchOverHops(String hops) { + return 'La récupération de la voix sur $hops sauts peut prendre un moment.'; + } + + @override + String get voiceReceiveCanceled => 'Réception vocale annulée'; + + @override + String get fetchingMissingVoiceFragments => + 'Récupération des fragments vocaux manquants'; + + @override + String get receivingVoice => 'Réception de la voix'; + + @override + String get cannotFetchImage => 'Impossible de récupérer l\'image'; + + @override + String imageFetchOverHops(String hops) { + return 'La récupération de l\'image sur $hops sauts peut prendre un moment.'; + } + + @override + String get imageAlreadyBeingReceived => + 'L\'image est déjà en cours de réception'; + + @override + String get loadImage => 'Charger l\'image'; + + @override + String get imageFetchFailedToSendRequest => + 'Échec de l\'envoi de la demande d\'image'; + + @override + String get imageUnavailable => 'Image indisponible pour le moment'; + + @override + String get imageFetchTimedOut => 'Délai de récupération de l\'image dépassé'; + + @override + String get imageReceiveCanceled => 'Réception de l\'image annulée'; + + @override + String get closeImagePreview => 'Fermer l\'aperçu de l\'image'; + + @override + String get recordingReleaseToSend => + 'Enregistrement... relâchez pour envoyer'; + + @override + String get sendingVoice => 'Envoi du message vocal...'; + + @override + String get sendLongPressToRecordVoice => + 'Envoyer (appui long pour enregistrer la voix)'; + + @override + String get discardRecording => 'Abandonner l\'enregistrement'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count sauts', + one: '$count saut', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Canaux de diffusion pour le trafic mesh à proximité'; + + @override + String get roomsSectionDescription => + 'Espaces partagés pour la coordination d\'équipe'; + + @override + String get contactsSectionDescription => + 'Personnes et appareils joignables directement'; + + @override + String get noRecentChatters => 'Aucun participant récent'; + + @override + String get public => 'Public'; + + @override + String get failedToSendTicTacToeMove => + 'Échec de l\'envoi du coup de morpion'; + + @override + String get youWon => 'Vous avez gagné'; + + @override + String get opponentWon => 'L\'adversaire a gagné'; + + @override + String get gameDraw => 'Match nul'; + + @override + String get yourTurn => 'À vous de jouer'; + + @override + String get opponentTurn => 'Au tour de l\'adversaire'; + + @override + String get pleaseEnterValidCoordinates => + 'Veuillez saisir des coordonnées valides'; + + @override + String get locationNotAvailable => 'Position non disponible'; + + @override + String get customCaveMapPoint => 'Point de carte de grotte personnalisé'; + + @override + String get mapPoint => 'Point de carte'; + + @override + String get fetchingMissingImageFragments => + 'Récupération des fragments manquants…'; + + @override + String get loadingImage => 'Chargement…'; + + @override + String get receivingImage => 'Réception…'; + + @override + String get tapToLoad => 'Touchez pour charger'; + + @override + String get connectDevice => 'Connecter l\'appareil'; + + @override + String get chooseTransportSubtitle => + 'Choisissez le transport Bluetooth, WiFi ou Série'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Réseau'; + + @override + String get serial => 'Série'; + + @override + String get scan => 'Rechercher'; + + @override + String get pressScanToSearchForDevices => + 'Appuyez sur Rechercher pour trouver les appareils à proximité'; + + @override + String failedToConnectToDevice(String name) { + return 'Échec de la connexion à $name'; + } + + @override + String get unknownDevice => 'Appareil inconnu'; + + @override + String get showingCachedResultsTapRefresh => + 'Affichage des résultats en cache. Touchez Actualiser pour relancer la recherche.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Recherche d\'appareils MeshCore WiFi sur le réseau local, port $port'; + } + + @override + String get cancelScanAndAddServer => + 'Annuler la recherche et ajouter un serveur'; + + @override + String get addServer => 'Ajouter un serveur'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Recherche... $scanned/$total IP'; + } + + @override + String get cancelAndEnterManually => 'Annuler et saisir manuellement'; + + @override + String get noRecentOrDiscoveredServers => + 'Aucun serveur récent ou découvert pour le moment'; + + @override + String get recentlyUsed => 'Utilisés récemment'; + + @override + String get connecting => 'Connexion...'; + + @override + String get discoveredOnThisNetwork => 'Découverts sur ce réseau'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'Le serveur $host:$port n\'est plus disponible. Veuillez relancer la recherche pour trouver des serveurs actifs.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Échec de la connexion à $host:$port'; + } + + @override + String get enterValidIpAddress => 'Saisissez une adresse IP valide'; + + @override + String get enterValidTcpPort => 'Saisissez un port TCP valide'; + + @override + String get ipAddress => 'Adresse IP'; + + @override + String get tcpPort => 'Port TCP'; + + @override + String get customServerPort => 'Port de serveur personnalisé'; + + @override + String get chat => 'Chat'; + + @override + String get restoringPreviousLink => 'Restauration de la liaison précédente'; + + @override + String get noDeviceConnected => 'Aucun appareil connecté'; + + @override + String get sendAdvert => 'Envoyer mon contact'; + + @override + String get advertMode => 'Envoyer mon contact'; + + @override + String get advertModeSubtitle => + 'Choisissez jusqu\'où vos informations de contact doivent être diffusées.'; + + @override + String get floodAdvertSent => 'Contact envoyé à travers le réseau mesh'; + + @override + String get directAdvertSent => 'Contact envoyé aux appareils à proximité'; +} diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart new file mode 100644 index 0000000..8adf029 --- /dev/null +++ b/lib/l10n/app_localizations_hr.dart @@ -0,0 +1,3436 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Croatian (`hr`). +class AppLocalizationsHr extends AppLocalizations { + AppLocalizationsHr([String locale = 'hr']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Poruke'; + + @override + String get contacts => 'Kontakti'; + + @override + String get map => 'Karta'; + + @override + String get settings => 'Postavke'; + + @override + String get connect => 'Poveži'; + + @override + String get disconnect => 'Prekini'; + + @override + String get noDevicesFound => 'Nisu pronađeni uređaji'; + + @override + String get scanAgain => 'Skeniraj ponovno'; + + @override + String get deviceNotConnected => 'Uređaj nije povezan'; + + @override + String get locationPermissionDenied => 'Dopuštenje za lokaciju odbijeno'; + + @override + String get locationPermissionPermanentlyDenied => + 'Dopuštenje za lokaciju trajno odbijeno. Molimo omogućite u Postavkama.'; + + @override + String get locationPermissionRequired => + 'Dopuštenje za lokaciju potrebno je za GPS praćenje i koordinaciju tima. Možete ga omogućiti kasnije u Postavkama.'; + + @override + String get locationServicesDisabled => + 'Usluge lokacije su onemogućene. Molimo omogućite ih u Postavkama.'; + + @override + String get failedToGetGpsLocation => 'Neuspjelo dobivanje GPS lokacije'; + + @override + String failedToAdvertise(String error) { + return 'Neuspjela objava: $error'; + } + + @override + String get cancelReconnection => 'Otkaži ponovno povezivanje'; + + @override + String get general => 'Općenito'; + + @override + String get theme => 'Tema'; + + @override + String get chooseTheme => 'Odaberite temu'; + + @override + String get light => 'Svijetla'; + + @override + String get dark => 'Tamna'; + + @override + String get blueLightTheme => 'Plava svijetla tema'; + + @override + String get blueDarkTheme => 'Plava tamna tema'; + + @override + String get sarRed => 'SAR crvena'; + + @override + String get alertEmergencyMode => 'Način upozorenja/hitna situacija'; + + @override + String get sarGreen => 'SAR zelena'; + + @override + String get safeAllClearMode => 'Način sigurno/sve jasno'; + + @override + String get autoSystem => 'Automatski (Sustav)'; + + @override + String get followSystemTheme => 'Slijedi temu sustava'; + + @override + String get showRxTxIndicators => 'Prikaži RX/TX indikatore'; + + @override + String get disableMap => 'Onemogući kartu'; + + @override + String get language => 'Jezik'; + + @override + String get chooseLanguage => 'Odaberite jezik'; + + @override + String get save => 'Spremi'; + + @override + String get cancel => 'Otkaži'; + + @override + String get close => 'Zatvori'; + + @override + String get about => 'O aplikaciji'; + + @override + String get appVersion => 'Verzija aplikacije'; + + @override + String get aboutMeshCoreSar => 'O MeshCore SAR'; + + @override + String get 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'; + + @override + String get technologiesUsed => 'Korištene tehnologije:'; + + @override + String get technologiesList => + '• Flutter za višeplatformski razvoj\n• BLE (Bluetooth Low Energy) za mesh mrežu\n• OpenStreetMap za kartografiju\n• Provider za upravljanje stanjem\n• SharedPreferences za lokalnu pohranu'; + + @override + String get moreInfo => 'Više informacija'; + + @override + String get packageName => 'Ime paketa'; + + @override + String get sampleData => 'Primjer podataka'; + + @override + String get sampleDataDescription => + 'Učitajte ili očistite primjere kontakata, poruka kanala i SAR markera za testiranje'; + + @override + String get loadSampleData => 'Učitaj primjer'; + + @override + String get clearAllData => 'Očisti sve podatke'; + + @override + String get clearAllDataConfirmTitle => 'Očisti sve podatke'; + + @override + String get clearAllDataConfirmMessage => + 'Ovo će očistiti sve kontakte i SAR markere. Jeste li sigurni?'; + + @override + String get clear => 'Očisti'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Učitano $teamCount članova tima, $channelCount kanala, $sarCount SAR markera, $messageCount poruka'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Neuspjelo učitavanje primjera podataka: $error'; + } + + @override + String get allDataCleared => 'Svi podaci očišćeni'; + + @override + String get failedToStartBackgroundTracking => + 'Neuspjelo pokretanje praćenja u pozadini. Provjerite dopuštenja i BLE vezu.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Emitiranje lokacije: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'Zadani PIN za uređaje bez zaslona je 123456. Problemi s uparivanjem? Zaboravite Bluetooth uređaj u postavkama sustava.'; + + @override + String get noMessagesYet => 'Još nema poruka'; + + @override + String get pullDownToSync => 'Povucite prema dolje za sinkronizaciju'; + + @override + String get deleteContact => 'Izbriši kontakt'; + + @override + String get delete => 'Izbriši'; + + @override + String get viewOnMap => 'Prikaži na karti'; + + @override + String get refresh => 'Osvježi'; + + @override + String get resetPath => 'Resetiraj put (preusmjeri)'; + + @override + String copiedToClipboard(String label) { + return '$label kopirano u međuspremnik'; + } + + @override + String get pleaseEnterPassword => 'Molimo unesite lozinku'; + + @override + String failedToSyncContacts(String error) { + return 'Neuspjela sinkronizacija kontakata: $error'; + } + + @override + String get loggedInSuccessfully => + 'Uspješno prijavljen! Čekanje na poruke sobe...'; + + @override + String get loginFailed => 'Prijava neuspjela - netočna lozinka'; + + @override + String loggingIn(String roomName) { + return 'Prijavljivanje u $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Neuspjelo slanje prijave: $error'; + } + + @override + String get lowLocationAccuracy => 'Niska točnost lokacije'; + + @override + String get continue_ => 'Nastavi'; + + @override + String get sendSarMarker => 'Pošalji SAR marker'; + + @override + String get deleteDrawing => 'Izbriši crtež'; + + @override + String get drawingTools => 'Alati za crtanje'; + + @override + String get drawLine => 'Nacrtaj liniju'; + + @override + String get drawLineDesc => 'Nacrtaj slobodnu liniju na karti'; + + @override + String get drawRectangle => 'Nacrtaj pravokutnik'; + + @override + String get drawRectangleDesc => 'Nacrtaj pravokutno područje na karti'; + + @override + String get measureDistance => 'Izmjeri udaljenost'; + + @override + String get measureDistanceDesc => 'Dugi pritisak na dvije točke za mjerenje'; + + @override + String get clearMeasurement => 'Očisti mjerenje'; + + @override + String distanceLabel(String distance) { + return 'Udaljenost: $distance'; + } + + @override + String get longPressForSecondPoint => 'Dugi pritisak za drugu točku'; + + @override + String get longPressToStartMeasurement => 'Dugi pritisak za prvu točku'; + + @override + String get longPressToStartNewMeasurement => 'Dugi pritisak za novo mjerenje'; + + @override + String get shareDrawings => 'Podijeli crteže'; + + @override + String get clearAllDrawings => 'Očisti sve crteže'; + + @override + String get completeLine => 'Završi liniju'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Objavi $count crtež$plural timu'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Ukloni svih $count crtež$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Izbrisati sve $count crtež$plural s karte?'; + } + + @override + String get drawing => 'Crtanje'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Podijeli $count crtež$plural'; + } + + @override + String get showReceivedDrawings => 'Prikaži primljene crteže'; + + @override + String get showingAllDrawings => 'Prikazujem sve crteže'; + + @override + String get showingOnlyYourDrawings => 'Prikazujem samo vaše crteže'; + + @override + String get showSarMarkers => 'Prikaži SAR oznake'; + + @override + String get showingSarMarkers => 'Prikazujem SAR oznake'; + + @override + String get hidingSarMarkers => 'Skrivam SAR oznake'; + + @override + String get clearAll => 'Očisti sve'; + + @override + String get publicChannel => 'Javni kanal'; + + @override + String get broadcastToAll => 'Emitiraj svim obližnjim čvorovima (privremeno)'; + + @override + String get storedPermanently => 'Trajno pohranjeno u sobi'; + + @override + String get notConnectedToDevice => 'Nije povezano s uređajem'; + + @override + String get typeYourMessage => 'Upišite svoju poruku...'; + + @override + String get quickLocationMarker => 'Brzi označitelj lokacije'; + + @override + String get markerType => 'Vrsta markera'; + + @override + String get sendTo => 'Pošalji na'; + + @override + String get noDestinationsAvailable => 'Nema dostupnih odredišta.'; + + @override + String get selectDestination => 'Odaberite odredište...'; + + @override + String get ephemeralBroadcastInfo => + 'Privremeno: Samo emitiranje. Nije pohranjeno - čvorovi moraju biti online.'; + + @override + String get persistentRoomInfo => + 'Trajno: Nepromjenjivo pohranjeno u sobi. Automatski sinkronizirano i očuvano offline.'; + + @override + String get location => 'Lokacija'; + + @override + String get fromMap => 'S karte'; + + @override + String get gettingLocation => 'Dohvaćanje lokacije...'; + + @override + String get locationError => 'Greška lokacije'; + + @override + String get retry => 'Pokušaj ponovno'; + + @override + String get refreshLocation => 'Osvježi lokaciju'; + + @override + String accuracyMeters(int accuracy) { + return 'Točnost: ±${accuracy}m'; + } + + @override + String get notesOptional => 'Napomene (opcionalno)'; + + @override + String get addAdditionalInformation => 'Dodajte dodatne informacije...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'Točnost lokacije je ±${accuracy}m. Ovo možda nije dovoljno precizno za SAR operacije.\n\nNastaviti svejedno?'; + } + + @override + String get loginToRoom => 'Prijava u sobu'; + + @override + String get enterPasswordInfo => + 'Unesite lozinku za pristup ovoj sobi. Lozinka će biti spremljena za buduću upotrebu.'; + + @override + String get password => 'Lozinka'; + + @override + String get enterRoomPassword => 'Unesite lozinku sobe'; + + @override + String get loggingInDots => 'Prijavljivanje...'; + + @override + String get login => 'Prijava'; + + @override + String failedToAddRoom(String error) { + return 'Neuspjelo dodavanje sobe na uređaj: $error\n\nSoba možda još nije oglašena.\nPokušajte pričekati da soba emitira.'; + } + + @override + String get direct => 'Izravno'; + + @override + String get flood => 'Preplavljanje'; + + @override + String get autoSend => 'Automatsko slanje'; + + @override + String get autoSendDescription => 'Pošalji trenutnom rutom.'; + + @override + String get sendDirect => 'Izravno slanje'; + + @override + String get sendDirectDescription => 'Pošalji izravno ovom kontaktu.'; + + @override + String get sendFlood => 'Slanje poplavom'; + + @override + String get sendFloodDescription => 'Pošalji preko svih repetitora.'; + + @override + String get loggedIn => 'Prijavljen'; + + @override + String get noGpsData => 'Nema GPS podataka'; + + @override + String get distance => 'Udaljenost'; + + @override + String directPingTimeout(String name) { + return 'Istek izravnog pinga - ponovni pokušaj $name s preplavljanjem...'; + } + + @override + String pingFailed(String name) { + return 'Ping neuspješan prema $name - nije primljen odgovor'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Jeste li sigurni da želite izbrisati \"$name\"?\n\nOvo će ukloniti kontakt iz aplikacije i pratećeg radio uređaja.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Neuspjelo uklanjanje kontakta: $error'; + } + + @override + String get type => 'Vrsta'; + + @override + String get publicKey => 'Javni ključ'; + + @override + String get lastSeen => 'Zadnje viđen'; + + @override + String get yes => 'Da'; + + @override + String get no => 'Ne'; + + @override + String get permissions => 'Dopuštenja'; + + @override + String get telemetry => 'Telemetrija'; + + @override + String get voltage => 'Napon'; + + @override + String get battery => 'Baterija'; + + @override + String get temperature => 'Temperatura'; + + @override + String get humidity => 'Vlažnost'; + + @override + String get pressure => 'Tlak'; + + @override + String get gpsTelemetry => 'GPS (Telemetrija)'; + + @override + String get reLoginToRoom => 'Ponovna prijava u sobu'; + + @override + String get heading => 'Smjer'; + + @override + String get elevation => 'Nadmorska visina'; + + @override + String get accuracy => 'Točnost'; + + @override + String get bearing => 'Azimut'; + + @override + String get direction => 'Smjer'; + + @override + String get filterMarkers => 'Filtriraj markere'; + + @override + String get filterMarkersTooltip => 'Filtriraj markere'; + + @override + String get contactsFilter => 'Kontakti'; + + @override + String get repeatersFilter => 'Repetitori'; + + @override + String get sarMarkers => 'SAR markeri'; + + @override + String get foundPerson => 'Pronađena osoba'; + + @override + String get fire => 'Požar'; + + @override + String get stagingArea => 'Zbirno mjesto'; + + @override + String get showAll => 'Prikaži sve'; + + @override + String get locationUnavailable => 'Lokacija nije dostupna'; + + @override + String get ahead => 'naprijed'; + + @override + String degreesRight(int degrees) { + return '$degrees° desno'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° lijevo'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Šir: $latitude Duž: $longitude'; + } + + @override + String get noContactsYet => 'Još nema kontakata'; + + @override + String get connectToDeviceToLoadContacts => + 'Povežite se s uređajem da učitate kontakte'; + + @override + String get teamMembers => 'Članovi tima'; + + @override + String get repeaters => 'Repetitori'; + + @override + String get rooms => 'Sobe'; + + @override + String get channels => 'Kanali'; + + @override + String get selectMapLayer => 'Odaberite sloj karte'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'ESRI satelit'; + + @override + String get googleHybrid => 'Google hibridna karta'; + + @override + String get googleRoadmap => 'Google cestovna karta'; + + @override + String get googleTerrain => 'Google teren'; + + @override + String get dragToPosition => 'Povuci na poziciju'; + + @override + String get createSarMarker => 'Kreiraj SAR marker'; + + @override + String get compass => 'Kompas'; + + @override + String get navigationAndContacts => 'Navigacija i kontakti'; + + @override + String get sarAlert => 'SAR UZBUNA'; + + @override + String get textCopiedToClipboard => 'Tekst kopiran u međuspremnik'; + + @override + String get cannotReplySenderMissing => + 'Ne mogu odgovoriti: informacije o pošiljatelju nedostaju'; + + @override + String get cannotReplyContactNotFound => + 'Ne mogu odgovoriti: kontakt nije pronađen'; + + @override + String get copyText => 'Kopiraj tekst'; + + @override + String get saveAsTemplate => 'Spremi kao predložak'; + + @override + String get templateSaved => 'Predložak uspješno spremljen'; + + @override + String get templateAlreadyExists => 'Predložak s ovim emojijem već postoji'; + + @override + String get deleteMessage => 'Izbriši poruku'; + + @override + String get deleteMessageConfirmation => + 'Jeste li sigurni da želite izbrisati ovu poruku?'; + + @override + String get shareLocation => 'Podijeli lokaciju'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nKoordinate: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'SAR Lokacija'; + + @override + String get justNow => 'Upravo sada'; + + @override + String minutesAgo(int minutes) { + return 'prije ${minutes}m'; + } + + @override + String hoursAgo(int hours) { + return 'prije ${hours}h'; + } + + @override + String daysAgo(int days) { + return 'prije ${days}d'; + } + + @override + String secondsAgo(int seconds) { + return 'prije ${seconds}s'; + } + + @override + String get sending => 'Slanje...'; + + @override + String get sent => 'Poslano'; + + @override + String get delivered => 'Dostavljeno'; + + @override + String deliveredWithTime(int time) { + return 'Dostavljeno (${time}ms)'; + } + + @override + String get failed => 'Neuspjelo'; + + @override + String get broadcast => 'Emitirano'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Dostavljeno na $delivered/$total kontakata'; + } + + @override + String get allDelivered => 'Sve dostavljeno'; + + @override + String get recipientDetails => 'Detalji primatelja'; + + @override + String get pending => 'Na čekanju'; + + @override + String get sarMarkerFoundPerson => 'Pronađena osoba'; + + @override + String get sarMarkerFire => 'Lokacija požara'; + + @override + String get sarMarkerStagingArea => 'Zbirno mjesto'; + + @override + String get sarMarkerObject => 'Pronađen objekt'; + + @override + String get from => 'Od'; + + @override + String get coordinates => 'Koordinate'; + + @override + String get tapToViewOnMap => 'Dodirnite za prikaz na karti'; + + @override + String get radioSettings => 'Postavke radija'; + + @override + String get frequencyMHz => 'Frekvencija (MHz)'; + + @override + String get bandwidth => 'Širina pojasa'; + + @override + String get spreadingFactor => 'Faktor širenja'; + + @override + String get codingRate => 'Omjer kodiranja'; + + @override + String get txPowerDbm => 'TX snaga (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Maks: $power dBm'; + } + + @override + String get you => 'Ti'; + + @override + String exportFailed(String error) { + return 'Izvoz nije uspio: $error'; + } + + @override + String importFailed(String error) { + return 'Uvoz nije uspio: $error'; + } + + @override + String get unknown => 'Nepoznato'; + + @override + String get onlineLayers => 'Mrežni slojevi'; + + @override + String get locationTrail => 'Putanja lokacije'; + + @override + String get showTrailOnMap => 'Prikaži putanju na karti'; + + @override + String get trailVisible => 'Putanja je vidljiva na karti'; + + @override + String get trailHiddenRecording => 'Putanja je skrivena (još se snima)'; + + @override + String get duration => 'Trajanje'; + + @override + String get points => 'Točke'; + + @override + String get clearTrail => 'Obriši putanju'; + + @override + String get clearTrailQuestion => 'Obrisati putanju?'; + + @override + String get clearTrailConfirmation => + 'Jeste li sigurni da želite obrisati trenutnu putanju lokacije? Ova radnja se ne može poništiti.'; + + @override + String get noTrailRecorded => 'Još nije snimljena putanja'; + + @override + String get startTrackingToRecord => + 'Pokrenite praćenje lokacije za snimanje putanje'; + + @override + String get trailControls => 'Upravljanje putanjom'; + + @override + String get model => 'Model'; + + @override + String get version => 'Verzija'; + + @override + String get maxContacts => 'Maks. kontakata'; + + @override + String get maxChannels => 'Maks. kanala'; + + @override + String get publicInfo => 'Javne informacije'; + + @override + String get useCurrentLocation => 'Koristi trenutnu lokaciju'; + + @override + String get noneUnknown => 'Nema/Nepoznato'; + + @override + String get chatNode => 'Čvorište za razgovor'; + + @override + String get repeater => 'Repetitor'; + + @override + String get roomChannel => 'Soba/Kanal'; + + @override + String typeNumber(int number) { + return 'Tip $number'; + } + + @override + String failedToSave(String error) { + return 'Neuspjelo spremanje: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Neuspjelo dohvaćanje lokacije: $error'; + } + + @override + String get sarTemplates => 'SAR predlošci'; + + @override + String get addTemplate => 'Dodaj predložak'; + + @override + String get editTemplate => 'Uredi predložak'; + + @override + String get deleteTemplate => 'Izbriši predložak'; + + @override + String get templateName => 'Naziv predloška'; + + @override + String get templateNameHint => 'npr. Pronađena osoba'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji je obavezan'; + + @override + String get nameRequired => 'Ime je obavezno'; + + @override + String get templateDescription => 'Opis (neobavezno)'; + + @override + String get templateDescriptionHint => 'Dodajte dodatni kontekst...'; + + @override + String get templateColor => 'Boja'; + + @override + String get previewFormat => 'Pregled (format SAR poruke)'; + + @override + String get importFromClipboard => 'Uvezi'; + + @override + String get exportToClipboard => 'Izvezi'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Izbrisati predložak \'$name\'?'; + } + + @override + String get templateAdded => 'Predložak dodan'; + + @override + String get templateUpdated => 'Predložak ažuriran'; + + @override + String get templateDeleted => 'Predložak izbrisan'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Uvezeno $count predložaka', + one: 'Uvezen 1 predložak', + zero: 'Nema uvezenih predložaka', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Izvezeno $count predložaka u međuspremnik', + one: 'Izvezen 1 predložak u međuspremnik', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Vrati na zadane'; + + @override + String get resetToDefaultsConfirmation => + 'Ovo će izbrisati sve prilagođene predloške i vratiti 4 zadana predloška. Nastaviti?'; + + @override + String get reset => 'Vrati'; + + @override + String get resetComplete => 'Predlošci vraćeni na zadane'; + + @override + String get noTemplates => 'Nema dostupnih predložaka'; + + @override + String get tapAddToCreate => 'Dodirnite + za izradu prvog predloška'; + + @override + String get ok => 'U redu'; + + @override + String get locationPermission => 'Dozvola za lokaciju'; + + @override + String get checking => 'Provjera...'; + + @override + String get locationPermissionGrantedAlways => 'Odobreno (Uvijek)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Odobreno (Tijekom uporabe)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Odbijeno - Dodirnite za zahtjev'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Trajno odbijeno - Otvori postavke'; + + @override + String get locationPermissionDialogContent => + 'Dozvola za lokaciju je trajno odbijena. Omogućite je u postavkama uređaja kako biste koristili GPS praćenje i dijeljenje lokacije.'; + + @override + String get openSettings => 'Otvori postavke'; + + @override + String get locationPermissionGranted => 'Dozvola za lokaciju odobrena!'; + + @override + String get locationPermissionRequiredForGps => + 'Dozvola za lokaciju je potrebna za GPS praćenje i dijeljenje lokacije.'; + + @override + String get locationPermissionAlreadyGranted => + 'Dozvola za lokaciju je već odobrena.'; + + @override + String get sarNavyBlue => 'SAR Mornarsko Plava'; + + @override + String get sarNavyBlueDescription => 'Profesionalni/Operativni Način'; + + @override + String get selectRecipient => 'Odaberi primatelja'; + + @override + String get broadcastToAllNearby => 'Emitiraj svima u blizini'; + + @override + String get searchRecipients => 'Pretraži primatelje...'; + + @override + String get noContactsFound => 'Nema kontakata'; + + @override + String get noRoomsFound => 'Nema soba'; + + @override + String get noRecipientsAvailable => 'Nema dostupnih primatelja'; + + @override + String get noChannelsFound => 'Nije pronađen nijedan kanal'; + + @override + String get newMessage => 'Nova poruka'; + + @override + String get channel => 'Kanal'; + + @override + String get samplePoliceLead => 'Voditelj Policije'; + + @override + String get sampleDroneOperator => 'Operater Drona'; + + @override + String get sampleFirefighterAlpha => 'Vatrogasac'; + + @override + String get sampleMedicCharlie => 'Medičar'; + + @override + String get sampleCommandDelta => 'Zapovjedništvo'; + + @override + String get sampleFireEngine => 'Vatrogasno Vozilo'; + + @override + String get sampleAirSupport => 'Zračna Podrška'; + + @override + String get sampleBaseCoordinator => 'Koordinator Baze'; + + @override + String get channelEmergency => 'Hitno'; + + @override + String get channelCoordination => 'Koordinacija'; + + @override + String get channelUpdates => 'Ažuriranja'; + + @override + String get sampleTeamMember => 'Primjer Člana Tima'; + + @override + String get sampleScout => 'Primjer Izviđača'; + + @override + String get sampleBase => 'Primjer Baze'; + + @override + String get sampleSearcher => 'Primjer Tragača'; + + @override + String get sampleObjectBackpack => ' Pronađen ruksak - plava boja'; + + @override + String get sampleObjectVehicle => ' Napušteno vozilo - provjeriti vlasnika'; + + @override + String get sampleObjectCamping => ' Otkrivena oprema za kampiranje'; + + @override + String get sampleObjectTrailMarker => ' Oznaka staze pronađena izvan puta'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Svi timovi, javite se'; + + @override + String get sampleMsgWeatherUpdate => + 'Ažuriranje vremena: Vedro nebo, temp 18°C'; + + @override + String get sampleMsgBaseCamp => 'Bazni kamp uspostavljen na okupljalištu'; + + @override + String get sampleMsgTeamAlpha => 'Tim se kreće prema sektoru 2'; + + @override + String get sampleMsgRadioCheck => 'Provjera radija - sve stanice odgovorite'; + + @override + String get sampleMsgWaterSupply => + 'Opskrba vodom dostupna na kontrolnoj točki 3'; + + @override + String get sampleMsgTeamBravo => 'Tim izvještava: sektor 1 čist'; + + @override + String get sampleMsgEtaRallyPoint => 'ETA do točke okupljanja: 15 minuta'; + + @override + String get sampleMsgSupplyDrop => 'Isporuka zaliha potvrđena za 14:00'; + + @override + String get sampleMsgDroneSurvey => 'Nadzor dronom završen - bez nalaza'; + + @override + String get sampleMsgTeamCharlie => 'Tim traži pojačanje'; + + @override + String get sampleMsgRadioDiscipline => + 'Sve jedinice: održavati radio disciplinu'; + + @override + String get sampleMsgUrgentMedical => + 'HITNO: Potrebna medicinska pomoć u sektoru 4'; + + @override + String get sampleMsgAdultMale => ' Odrasli muškarac, pri svijesti'; + + @override + String get sampleMsgFireSpotted => 'Uočen požar - koordinate slijede'; + + @override + String get sampleMsgSpreadingRapidly => ' Širi se brzo!'; + + @override + String get sampleMsgPriorityHelicopter => + 'PRIORITET: Potrebna podrška helikoptera'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Medicinski tim na putu do vaše lokacije'; + + @override + String get sampleMsgEvacHelicopter => + 'Helikopter za evakuaciju ETA 10 minuta'; + + @override + String get sampleMsgEmergencyResolved => 'Hitnost riješena - sve čisto'; + + @override + String get sampleMsgEmergencyStagingArea => ' Hitno okupljalište'; + + @override + String get sampleMsgEmergencyServices => + 'Hitne službe obaviještene i odgovaraju'; + + @override + String get sampleAlphaTeamLead => 'Voditelj Tima'; + + @override + String get sampleBravoScout => 'Izviđač'; + + @override + String get sampleCharlieMedic => 'Medičar'; + + @override + String get sampleDeltaNavigator => 'Navigator'; + + @override + String get sampleEchoSupport => 'Podrška'; + + @override + String get sampleBaseCommand => 'Zapovjedništvo Baze'; + + @override + String get sampleFieldCoordinator => 'Terenski Koordinator'; + + @override + String get sampleMedicalTeam => 'Medicinski Tim'; + + @override + String get mapDrawing => 'Crtež karte'; + + @override + String get navigateToDrawing => 'Navigiraj do crteža'; + + @override + String get copyCoordinates => 'Kopiraj koordinate'; + + @override + String get hideFromMap => 'Sakrij s karte'; + + @override + String get lineDrawing => 'Linijski crtež'; + + @override + String get rectangleDrawing => 'Pravokutni crtež'; + + @override + String get manualCoordinates => 'Ručne koordinate'; + + @override + String get enterCoordinatesManually => 'Ručno unesite koordinate'; + + @override + String get latitudeLabel => 'Geografska širina'; + + @override + String get longitudeLabel => 'Geografska dužina'; + + @override + String get exampleCoordinates => 'Primjer: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Podijeli crtež'; + + @override + String get shareWithAllNearbyDevices => + 'Podijeli sa svim obližnjim uređajima'; + + @override + String get shareToRoom => 'Podijeli u Sobu'; + + @override + String get sendToPersistentStorage => 'Pošalji u trajnu pohranu sobe'; + + @override + String get deleteDrawingConfirm => + 'Jeste li sigurni da želite izbrisati ovaj crtež?'; + + @override + String get drawingDeleted => 'Crtež izbrisan'; + + @override + String yourDrawingsCount(int count) { + return 'Vaši crteži ($count)'; + } + + @override + String get shared => 'Podijeljeno'; + + @override + String get line => 'Linija'; + + @override + String get rectangle => 'Pravokutnik'; + + @override + String get updateAvailable => 'Dostupno ažuriranje'; + + @override + String get currentVersion => 'Trenutna verzija'; + + @override + String get latestVersion => 'Najnovija verzija'; + + @override + String get downloadUpdate => 'Preuzmi ažuriranje'; + + @override + String get updateLater => 'Kasnije'; + + @override + String get cadastralParcels => 'Katastarske čestice'; + + @override + String get forestRoads => 'Šumske ceste'; + + @override + String get wmsOverlays => 'WMS prekrivanja'; + + @override + String get hikingTrails => 'Planinske staze'; + + @override + String get mainRoads => 'Glavne ceste'; + + @override + String get houseNumbers => 'Kućni brojevi'; + + @override + String get fireHazardZones => 'Požarna ugroženost'; + + @override + String get historicalFires => 'Povijesni požari'; + + @override + String get firebreaks => 'Protupožarni pojasi'; + + @override + String get krasFireZones => 'Kraška požarišta'; + + @override + String get placeNames => 'Zemljopisna imena'; + + @override + String get municipalityBorders => 'Općinske granice'; + + @override + String get topographicMap => 'Topografska karta 1:25000'; + + @override + String get recentMessages => 'Nedavne poruke'; + + @override + String get addChannel => 'Dodaj kanal'; + + @override + String get channelName => 'Ime kanala'; + + @override + String get channelNameHint => 'npr. Spasilačka ekipa Alfa'; + + @override + String get channelSecret => 'Lozinka kanala'; + + @override + String get channelSecretHint => 'Zajednička lozinka za ovaj kanal'; + + @override + String get channelSecretHelp => + 'Ova lozinka mora biti podijeljena sa svim članovima tima koji trebaju pristup ovom kanalu'; + + @override + String get channelTypesInfo => + 'Hash kanali (#tim): Lozinka automatski generirana iz imena. Isto ime = isti kanal na svim uređajima.\n\nPrivatni kanali: Koristite eksplicitnu lozinku. Samo oni s lozinkom se mogu pridružiti.'; + + @override + String get hashChannelInfo => + 'Hash kanal: Lozinka će biti automatski generirana iz imena kanala. Bilo tko tko koristi isto ime pridružit će se istom kanalu.'; + + @override + String get channelNameRequired => 'Ime kanala je obavezno'; + + @override + String get channelNameTooLong => 'Ime kanala mora imati najviše 31 znak'; + + @override + String get channelSecretRequired => 'Lozinka kanala je obavezna'; + + @override + String get channelSecretTooLong => + 'Lozinka kanala mora imati najviše 32 znaka'; + + @override + String get invalidAsciiCharacters => 'Samo ASCII znakovi su dozvoljeni'; + + @override + String get channelCreatedSuccessfully => 'Kanal uspješno kreiran'; + + @override + String channelCreationFailed(String error) { + return 'Neuspješno kreiranje kanala: $error'; + } + + @override + String get deleteChannel => 'Izbriši kanal'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Jeste li sigurni da želite izbrisati kanal \"$channelName\"? Ova radnja se ne može poništiti.'; + } + + @override + String get channelDeletedSuccessfully => 'Kanal uspješno izbrisan'; + + @override + String channelDeletionFailed(String error) { + return 'Neuspješno brisanje kanala: $error'; + } + + @override + String get createChannel => 'Kreiraj kanal'; + + @override + String get wizardBack => 'Natrag'; + + @override + String get wizardSkip => 'Preskoči'; + + @override + String get wizardNext => 'Dalje'; + + @override + String get wizardGetStarted => 'Započni'; + + @override + String get wizardWelcomeTitle => 'Dobrodošli u MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Pogledaj uputu dobrodošlice'; + + @override + String get allTeamContacts => 'Svi kontakti tima'; + + @override + String directMessagesInfo(int count) { + return 'Izravne poruke s potvrdom. Poslano $count članovima tima.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'SAR oznaka poslana $count kontaktima'; + } + + @override + String get noContactsAvailable => 'Nema dostupnih kontakata tima'; + + @override + String get reply => 'Odgovori'; + + @override + String get technicalDetails => 'Tehnički detalji'; + + @override + String get messageTechnicalDetails => 'Tehnički detalji poruke'; + + @override + String get linkQuality => 'Kvaliteta veze'; + + @override + String get delivery => 'Dostava'; + + @override + String get status => 'Status'; + + @override + String get expectedAckTag => 'Očekivana ACK oznaka'; + + @override + String get roundTrip => 'Povratno putovanje'; + + @override + String get retryAttempt => 'Pokušaj ponovnog slanja'; + + @override + String get floodFallback => 'Flood rezerva'; + + @override + String get identity => 'Identitet'; + + @override + String get messageId => 'ID poruke'; + + @override + String get sender => 'Pošiljatelj'; + + @override + String get senderKey => 'Ključ pošiljatelja'; + + @override + String get recipient => 'Primatelj'; + + @override + String get recipientKey => 'Ključ primatelja'; + + @override + String get voice => 'Glas'; + + @override + String get voiceId => 'ID glasa'; + + @override + String get envelope => 'Omotnica'; + + @override + String get sessionProgress => 'Napredak sesije'; + + @override + String get complete => 'Dovršeno'; + + @override + String get rawDump => 'Sirovi ispis'; + + @override + String get cannotRetryMissingRecipient => + 'Nije moguće ponoviti: nedostaju informacije o primatelju'; + + @override + String get voiceUnavailable => 'Glas trenutno nije dostupan'; + + @override + String get requestingVoice => 'Zahtjev za glasom'; + + @override + String get device => 'uređaj'; + + @override + String get change => 'Promijeni'; + + @override + String get wizardOverviewDescription => + 'Ova aplikacija objedinjuje MeshCore poruke, SAR ažuriranja s terena, karte i alate za uređaj na jednom mjestu.'; + + @override + String get wizardOverviewFeature1 => + 'Šaljite izravne poruke, objave u sobama i poruke kanala iz glavne kartice Poruke.'; + + @override + String get wizardOverviewFeature2 => + 'Dijelite SAR oznake, crteže karte, glasovne isječke i slike preko mesh mreže.'; + + @override + String get wizardOverviewFeature3 => + 'Povežite se putem BLE-a ili TCP-a, a zatim upravljajte pratećim radijem izravno iz aplikacije.'; + + @override + String get wizardMessagingTitle => 'Poruke i terenska izvješća'; + + @override + String get wizardMessagingDescription => + 'Poruke ovdje nisu samo običan tekst. Aplikacija već podržava više operativnih sadržaja i tijekova prijenosa.'; + + @override + String get wizardMessagingFeature1 => + 'Šaljite izravne poruke, objave u sobama i promet kanala iz jednog uređivača.'; + + @override + String get wizardMessagingFeature2 => + 'Stvarajte SAR ažuriranja i višekratne SAR predloške za uobičajena terenska izvješća.'; + + @override + String get wizardMessagingFeature3 => + 'Prenosite glasovne sesije i slike uz prikaz napretka i procjene vremena prijenosa u sučelju.'; + + @override + String get wizardConnectDeviceTitle => 'Poveži uređaj'; + + @override + String get wizardConnectDeviceDescription => + 'Povežite svoj MeshCore radio, odaberite naziv i primijenite radijski predložak prije nastavka.'; + + @override + String get wizardSetupBadge => 'Postavljanje'; + + @override + String get wizardOverviewBadge => 'Pregled'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Povezano s uređajem $deviceName'; + } + + @override + String get wizardNoDeviceConnected => 'Još nema povezanog uređaja'; + + @override + String get wizardSkipForNow => 'Preskoči za sada'; + + @override + String get wizardDeviceNameLabel => 'Naziv uređaja'; + + @override + String get wizardDeviceNameHelp => + 'Taj se naziv oglašava drugim korisnicima MeshCorea.'; + + @override + String get wizardConfigRegionLabel => 'Regija konfiguracije'; + + @override + String get wizardConfigRegionHelp => + 'Koristi puni službeni popis MeshCore predložaka. Zadana je opcija EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Provjerite odgovara li odabrani predložak lokalnim radijskim propisima.'; + + @override + String get wizardPresetNote2 => + 'Popis odgovara službenom izvoru predložaka alata MeshCore config.'; + + @override + String get wizardPresetNote3 => + 'EU/UK (Narrow) ostaje zadano odabran tijekom uvodnog postavljanja.'; + + @override + String get wizardSaving => 'Spremanje...'; + + @override + String get wizardSaveAndContinue => 'Spremi i nastavi'; + + @override + String get wizardEnterDeviceName => 'Unesite naziv uređaja prije nastavka.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return 'Spremljeno $deviceName s postavkom $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Kontakti, sobe i repetitori'; + + @override + String get wizardNetworkDescription => + 'Kartica Kontakti organizira mrežu koju otkrijete i rute koje s vremenom naučite.'; + + @override + String get wizardNetworkFeature1 => + 'Pregledajte članove tima, repetitore, sobe, kanale i oglase na čekanju na jednom popisu.'; + + @override + String get wizardNetworkFeature2 => + 'Koristite pametni ping, prijavu u sobe, naučene putanje i alate za resetiranje ruta kada povezanost postane neuredna.'; + + @override + String get wizardNetworkFeature3 => + 'Stvarajte kanale i upravljajte mrežnim odredištima bez napuštanja aplikacije.'; + + @override + String get wizardMapOpsTitle => 'Karta, tragovi i dijeljena geometrija'; + + @override + String get wizardMapOpsDescription => + 'Karta u aplikaciji izravno je povezana s porukama, praćenjem i SAR slojevima umjesto da bude zaseban preglednik.'; + + @override + String get wizardMapOpsFeature1 => + 'Pratite vlastitu poziciju, lokacije suigrača i tragove kretanja na karti.'; + + @override + String get wizardMapOpsFeature2 => + 'Otvorite crteže iz poruka, pregledajte ih unutar sučelja i po potrebi ih uklonite s karte.'; + + @override + String get wizardMapOpsFeature3 => + 'Koristite karte repetitora i dijeljene slojeve kako biste razumjeli doseg mreže na terenu.'; + + @override + String get wizardToolsTitle => 'Alati izvan poruka'; + + @override + String get wizardToolsDescription => + 'Ovdje postoji više od četiri glavne kartice. Aplikacija uključuje i konfiguraciju, dijagnostiku te opcionalne radne tokove senzora.'; + + @override + String get wizardToolsFeature1 => + 'Otvorite konfiguraciju uređaja kako biste promijenili radijske postavke, telemetriju, TX snagu i detalje pratećeg uređaja.'; + + @override + String get wizardToolsFeature2 => + 'Omogućite karticu Senzori kada želite nadzorne ploče praćenih senzora i brza osvježavanja.'; + + @override + String get wizardToolsFeature3 => + 'Koristite zapisnike paketa, skeniranje spektra i razvojnu dijagnostiku pri rješavanju problema s mesh mrežom.'; + + @override + String get postConnectDiscoveryTitle => 'Otkriti repetitore sada?'; + + @override + String get postConnectDiscoveryDescription => + 'Pokrenite otkrivanje repetitora odmah nakon povezivanja kako biste vidjeli obližnje MeshCore čvorove i brže ih dodali u svoju mrežu.'; + + @override + String get contactInSensors => 'U Senzorima'; + + @override + String get contactAddToSensors => 'Dodaj u Senzore'; + + @override + String get contactSetPath => 'Postavi putanju'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName dodan u Senzore'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Brisanje rute nije uspjelo: $error'; + } + + @override + String get contactRouteCleared => 'Ruta obrisana'; + + @override + String contactRouteSet(String route) { + return 'Ruta postavljena: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Postavljanje rute nije uspjelo: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'ACK istek vremena'; + + @override + String get opcode => 'Opkod'; + + @override + String get payload => 'Sadržaj'; + + @override + String get hops => 'Skokovi'; + + @override + String get hashSize => 'Veličina hash-a'; + + @override + String get pathBytes => 'Bajtovi putanje'; + + @override + String get selectedPath => 'Odabrana putanja'; + + @override + String get estimatedTx => 'Procijenjeno slanje'; + + @override + String get senderToReceipt => 'Od pošiljatelja do primitka'; + + @override + String get receivedCopies => 'Primljene kopije'; + + @override + String get retryCause => 'Razlog ponovnog pokušaja'; + + @override + String get retryMode => 'Način ponovnog pokušaja'; + + @override + String get retryResult => 'Rezultat ponovnog pokušaja'; + + @override + String get lastRetry => 'Zadnji pokušaj'; + + @override + String get rxPackets => 'RX paketi'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Brzina'; + + @override + String get window => 'Prozor'; + + @override + String get posttxDelay => 'Kašnjenje nakon slanja'; + + @override + String get bandpass => 'Pojasni filtar'; + + @override + String get bandpassFilterVoice => 'Pojasni filtar glasa'; + + @override + String get active => 'Aktivno'; + + @override + String get addContact => 'Dodaj kontakt'; + + @override + String get all => 'Sve'; + + @override + String get clearAllLabel => 'Očisti sve'; + + @override + String get clearFilters => 'Očisti filtre'; + + @override + String get clearRoute => 'Očisti putanju'; + + @override + String get clearMessages => 'Očisti poruke'; + + @override + String get clearScale => 'Očisti mjerilo'; + + @override + String get clearDiscoveries => 'Očisti otkrića'; + + @override + String get clearOnlineTraceDatabase => 'Očisti bazu mrežnih tragova'; + + @override + String get clearAllChannels => 'Očisti sve kanale'; + + @override + String get clearAllContacts => 'Očisti sve kontakte'; + + @override + String get clearChannels => 'Očisti kanale'; + + @override + String get clearContacts => 'Očisti kontakte'; + + @override + String get clearPathOnMaxRetry => 'Očisti putanju kod maks. pokušaja'; + + @override + String get create => 'Stvori'; + + @override + String get custom => 'Prilagođeno'; + + @override + String get defaultValue => 'Zadano'; + + @override + String get duplicate => 'Dupliciraj'; + + @override + String get editName => 'Uredi ime'; + + @override + String get open => 'Otvori'; + + @override + String get paste => 'Zalijepi'; + + @override + String get preview => 'Pregled'; + + @override + String get remove => 'Ukloni'; + + @override + String get rename => 'Preimenuj'; + + @override + String get resolveAll => 'Razriješi sve'; + + @override + String get send => 'Pošalji'; + + @override + String get sendAnyway => 'Svejedno pošalji'; + + @override + String get share => 'Dijeli'; + + @override + String get trace => 'Praćenje'; + + @override + String get discovery => 'Otkrivanje'; + + @override + String get discoverRepeaters => 'Otkrij posrednike'; + + @override + String get discoverSensors => 'Otkrij senzore'; + + @override + String get repeaterDiscoverySent => 'Otkrivanje posrednika poslano'; + + @override + String get sensorDiscoverySent => 'Otkrivanje senzora poslano'; + + @override + String get clearedPendingDiscoveries => 'Očišćena čekajuća otkrića.'; + + @override + String get autoDiscovery => 'Automatsko otkrivanje'; + + @override + String get enableAutomaticAdding => 'Omogući automatsko dodavanje'; + + @override + String get autoaddRepeaters => 'Automatski dodaj posrednike'; + + @override + String get autoaddRoomServers => 'Automatski dodaj poslužitelje soba'; + + @override + String get autoaddSensors => 'Automatski dodaj senzore'; + + @override + String get autoaddUsers => 'Automatski dodaj korisnike'; + + @override + String get overwriteOldestWhenFull => 'Prepiši najstarije kad je puno'; + + @override + String get storage => 'Pohrana'; + + @override + String get dangerZone => 'Opasna zona'; + + @override + String get profiles => 'Profili'; + + @override + String get favourites => 'Favoriti'; + + @override + String get sensors => 'Senzori'; + + @override + String get others => 'Ostali'; + + @override + String get gpsModule => 'GPS modul'; + + @override + String get liveTraffic => 'Promet uživo'; + + @override + String get repeatersMap => 'Karta posrednika'; + + @override + String get blePacketLogs => 'BLE zapisi paketa'; + + @override + String get onlineTraceDatabase => 'Baza mrežnih tragova'; + + @override + String get routePathByteSize => 'Veličina putanje u bajtima'; + + @override + String get messageNotifications => 'Obavijesti o porukama'; + + @override + String get sarAlerts => 'SAR upozorenja'; + + @override + String get discoveryNotifications => 'Obavijesti o otkrivanju'; + + @override + String get updateNotifications => 'Obavijesti o ažuriranju'; + + @override + String get muteWhileAppIsOpen => 'Utišaj dok je aplikacija otvorena'; + + @override + String get disableContacts => 'Onemogući kontakte'; + + @override + String get enableSensorsTab => 'Omogući karticu Senzori'; + + @override + String get enableProfiles => 'Omogući profile'; + + @override + String get nearestRepeaterFallback => 'Najbliži posrednik kao rezerva'; + + @override + String get deleteAllStoredMessageHistory => + 'Izbriši svu pohranjenu povijest poruka'; + + @override + String get messageFontSize => 'Veličina fonta poruka'; + + @override + String get rotateMapWithHeading => 'Rotiraj kartu prema smjeru'; + + @override + String get showMapDebugInfo => 'Prikaži debug info karte'; + + @override + String get openMapInFullscreen => 'Otvori kartu na cijelom zaslonu'; + + @override + String get showSarMarkersLabel => 'Prikaži SAR oznake'; + + @override + String get hideRepeatersOnMap => 'Sakrij posrednike na karti'; + + @override + String get setMapScale => 'Postavi mjerilo karte'; + + @override + String get customMapScaleSaved => 'Prilagođeno mjerilo karte spremljeno'; + + @override + String get voiceBitrate => 'Bitna brzina glasa'; + + @override + String get voiceCompressor => 'Kompresor glasa'; + + @override + String get voiceLimiter => 'Ograničivač glasa'; + + @override + String get micAutoGain => 'Automatsko pojačanje mikrofona'; + + @override + String get echoCancellation => 'Poništavanje odjeka'; + + @override + String get noiseSuppression => 'Potiskivanje šuma'; + + @override + String get trimSilenceInVoiceMessages => 'Izreži tišinu u glasovnim porukama'; + + @override + String get compressor => 'Kompresor'; + + @override + String get limiter => 'Ograničivač'; + + @override + String get autoGain => 'Automatsko pojačanje'; + + @override + String get echoCancel => 'Odjek'; + + @override + String get noiseSuppress => 'Šum'; + + @override + String get silenceTrim => 'Tišina'; + + @override + String get maxImageSize => 'Maksimalna veličina slike'; + + @override + String get imageCompression => 'Kompresija slike'; + + @override + String get grayscale => 'Sivine'; + + @override + String get ultraMode => 'Ultra način'; + + @override + String get fastPrivateGpsUpdates => 'Brza privatna GPS ažuriranja'; + + @override + String get movementThreshold => 'Prag kretanja'; + + @override + String get fastGpsMovementThreshold => 'Prag kretanja za brzi GPS'; + + @override + String get fastGpsActiveuseInterval => + 'Interval aktivnog korištenja brzog GPS-a'; + + @override + String get activeuseUpdateInterval => + 'Interval ažuriranja pri aktivnom korištenju'; + + @override + String get repeatNearbyTraffic => 'Ponovi obližnji promet'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Proslijedi kroz posrednike kroz mrežu'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Samo obližnji, bez preplavljivanja posrednika'; + + @override + String get multihop => 'Višeskočno'; + + @override + String get createProfile => 'Stvori profil'; + + @override + String get renameProfile => 'Preimenuj profil'; + + @override + String get newProfile => 'Novi profil'; + + @override + String get manageProfiles => 'Upravljaj profilima'; + + @override + String get enableProfilesToStartManagingThem => + 'Omogućite profile da biste ih počeli upravljati.'; + + @override + String get openMessage => 'Otvori poruku'; + + @override + String get jumpToTheRelatedSarMessage => 'Skoči na povezanu SAR poruku'; + + @override + String get removeSarMarker => 'Ukloni SAR oznaku'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Odaberite odredište za slanje SAR oznake'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'SAR oznaka emitirana na javni kanal'; + + @override + String get sarMarkerSentToRoom => 'SAR oznaka poslana u sobu'; + + @override + String get loadFromGallery => 'Učitaj iz galerije'; + + @override + String get replaceImage => 'Zamijeni sliku'; + + @override + String get selectFromGallery => 'Odaberi iz galerije'; + + @override + String get team => 'Tim'; + + @override + String get found => 'Pronađeno'; + + @override + String get staging => 'Sabirno mjesto'; + + @override + String get object => 'Objekt'; + + @override + String get quiet => 'Tiho'; + + @override + String get busy => 'Zauzeto'; + + @override + String get searchMessages => 'Pretraži poruke'; + + @override + String get sendImageFromGallery => 'Pošalji sliku iz galerije'; + + @override + String get takePhoto => 'Snimi fotografiju'; + + @override + String get allMessages => 'Sve poruke'; + + @override + String get sendToPublicChannel => 'Pošalji na javni kanal?'; + + @override + String get selectMarkerTypeAndDestination => + 'Odaberite vrstu oznake i odredište'; + + @override + String get noDestinationsAvailableLabel => 'Nema dostupnih odredišta'; + + @override + String get image => 'Slika'; + + @override + String get format => 'Format'; + + @override + String get dimensions => 'Dimenzije'; + + @override + String get segments => 'Segmenti'; + + @override + String get transfers => 'Prijenosi'; + + @override + String get downloadedBy => 'Preuzeo'; + + @override + String get saveDiscoverySettings => 'Spremi postavke otkrivanja'; + + @override + String get savePublicInfo => 'Spremi javne podatke'; + + @override + String get saveRadioSettings => 'Spremi radijske postavke'; + + @override + String get savePath => 'Spremi putanju'; + + @override + String get wipeDeviceData => 'Obriši podatke uređaja'; + + @override + String get wipeDevice => 'Obriši uređaj'; + + @override + String get destructiveDeviceActions => 'Destruktivne radnje na uređaju.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Odaberite predefiniranu postavku ili fino podesite radijske postavke.'; + + @override + String get used => 'Korišteno'; + + @override + String get total => 'Ukupno'; + + @override + String get renameValue => 'Preimenuj vrijednost'; + + @override + String get customizeFields => 'Prilagodi polja'; + + @override + String get livePreview => 'Pregled uživo'; + + @override + String get refreshSchedule => 'Raspored osvježavanja'; + + @override + String get noResponse => 'Nema odgovora'; + + @override + String get refreshing => 'Osvježavam'; + + @override + String get unavailable => 'Nedostupno'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Odaberite posrednika ili čvor za praćenje.'; + + @override + String get publicKeyLabel => 'Javni ključ'; + + @override + String get alreadyInContacts => 'Već u kontaktima'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Povežite se s uređajem prije dodavanja kontakata'; + + @override + String get fromContacts => 'Iz kontakata'; + + @override + String get onlineOnly => 'Samo povezani'; + + @override + String get inBoth => 'U oba'; + + @override + String get source => 'Izvor'; + + @override + String get allMessagesCleared => 'Sve poruke očišćene'; + + @override + String get onlineTraceDatabaseCleared => 'Baza mrežnih tragova očišćena'; + + @override + String get packetLogsCleared => 'Zapisi paketa očišćeni'; + + @override + String get hexDataCopiedToClipboard => 'Hex podaci kopirani u međuspremnik'; + + @override + String get developerModeEnabled => 'Razvojni način omogućen'; + + @override + String get developerModeDisabled => 'Razvojni način onemogućen'; + + @override + String get clipboardIsEmpty => 'Međuspremnik je prazan'; + + @override + String get contactImported => 'Kontakt uvezen'; + + @override + String get contactLinkCopiedToClipboard => + 'Poveznica na kontakt kopirana u međuspremnik'; + + @override + String get failedToExportContact => 'Izvoz kontakta nije uspio'; + + @override + String get noLogsToExport => 'Nema zapisa za izvoz'; + + @override + String get exportAsCsv => 'Izvezi kao CSV'; + + @override + String get exportAsText => 'Izvezi kao tekst'; + + @override + String get receivedRfc3339 => 'Primljeno (RFC3339)'; + + @override + String get buildTime => 'Vrijeme gradnje'; + + @override + String get downloadUrlNotAvailable => 'URL za preuzimanje nije dostupan'; + + @override + String get cannotOpenDownloadUrl => 'Ne mogu otvoriti URL za preuzimanje'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'Provjera ažuriranja dostupna je samo na Androidu'; + + @override + String get youAreRunningTheLatestVersion => 'Koristite najnoviju verziju'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Ažuriranje dostupno, ali URL za preuzimanje nije pronađen'; + + @override + String get startTictactoe => 'Pokreni Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe nije dostupan'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: protivnik nepoznat'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: čekanje na početak'; + + @override + String get acceptsShareLinks => 'Prihvaća dijeljene veze'; + + @override + String get supportsRawHex => 'Podržava sirovi hex'; + + @override + String get clipboardfriendly => 'Pogodno za međuspremnik'; + + @override + String get captured => 'Zabilježeno'; + + @override + String get size => 'Veličina'; + + @override + String get noCustomChannelsToClear => 'Nema prilagođenih kanala za brisanje.'; + + @override + String get noDeviceContactsToClear => 'Nema kontakata uređaja za brisanje.'; + + @override + String get aToZ => 'A-Ž'; + + @override + String get add => 'Dodaj'; + + @override + String get addAdditionalDetails => 'Dodaj dodatne detalje'; + + @override + String get addPeer => 'Dodaj čvor'; + + @override + String get addPeerManually => 'Ručno dodaj čvor'; + + @override + String addedToContacts(String name) { + return '$name dodan u kontakte'; + } + + @override + String addedToSensors(String name) { + return '$name dodan u Senzore'; + } + + @override + String get allowAll => 'Dopusti sve'; + + @override + String get anonymousRxStats => 'Anonimna RX statistika'; + + @override + String get appearance => 'Izgled'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Osnovna telemetrija'; + + @override + String get blePin => 'BLE PIN'; + + @override + String get buzzerAlerts => 'Zvučna upozorenja'; + + @override + String get cancelImageReceive => 'Otkaži primanje slike'; + + @override + String get choose => 'Odaberi'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Odaberite koje kartice i odjeljke kontakata prikazati'; + + @override + String get clearCache => 'Očisti predmemoriju'; + + @override + String get clearHistory => 'Očisti povijest'; + + @override + String get clearLiveView => 'Očisti prikaz uživo'; + + @override + String get clearLogs => 'Očisti zapise'; + + @override + String get clearOfflineCache => 'Očistiti offline predmemoriju?'; + + @override + String get clearOverlay => 'Očisti prekrivanje'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Očisti rutu samo nakon što svi pokušaji ne uspiju'; + + @override + String get codecBitrateAndAudioProcessing => + 'Kodek, bitna brzina i obrada zvuka'; + + @override + String get connectByIpAddress => 'Poveži se putem IP adrese'; + + @override + String get controlWhichAlertsYouReceive => + 'Upravljajte upozorenjima koja primate'; + + @override + String get copyFullHex => 'Kopiraj cijeli Hex'; + + @override + String get copyRawResponse => 'Kopiraj sirovi odgovor'; + + @override + String get couldNotLoadMetHistory => 'Nije moguće učitati MET povijest'; + + @override + String customizeSensor(String name) { + return 'Prilagodi $name'; + } + + @override + String get data => 'Podaci'; + + @override + String get deleteGroup => 'Izbriši grupu'; + + @override + String deleteStyleConfirm(String name) { + return 'Izbrisati $name?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Izbrisati ovaj $type?'; + } + + @override + String get deny => 'Odbij'; + + @override + String get deviceInfo => 'Informacije o uređaju'; + + @override + String get deviceSettings => 'Postavke uređaja'; + + @override + String get deviceTimeSynced => + 'Vrijeme uređaja sinkronizirano s ovim telefonom.'; + + @override + String get disableFastGpsPublishing => 'Onemogući brzo GPS objavljivanje'; + + @override + String get displayMarkersAndTraceDatabase => 'Prikaz, markeri i baza tragova'; + + @override + String get download => 'Preuzmi'; + + @override + String get environmentalTelemetry => 'Okolišna telemetrija'; + + @override + String errorCheckingForUpdates(String error) { + return 'Greška pri provjeri ažuriranja: $error'; + } + + @override + String errorGeneric(String error) { + return 'Greška: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Greška pri otvaranju preuzimanja: $error'; + } + + @override + String get exportLogs => 'Izvezi zapise'; + + @override + String get extraAggressiveAvifCompression => + 'Dodatno agresivna AVIF kompresija'; + + @override + String failedToAddContact(String error) { + return 'Neuspjelo dodavanje kontakta: $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Neuspjelo dodavanje $name: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Neuspjelo brisanje kanala: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Neuspjelo brisanje kontakata: $error'; + } + + @override + String get failedToConnectViaSerial => + 'Neuspjelo povezivanje putem serijskog porta'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Neuspjelo učitavanje slike za pregled: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Neuspjelo učitavanje traga: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Neuspjelo slanje SAR markera: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Neuspjelo postavljanje načina zujalice: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Neuspjelo postavljanje GPS načina: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Neuspjela sinkronizacija vremena uređaja: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Neuspjelo brisanje podataka uređaja: $error'; + } + + @override + String get fastGpsTargetChannel => 'Ciljni kanal brzog GPS-a'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Brza ažuriranja, pragovi i dopuštenja'; + + @override + String get fetchingCatalog => 'Dohvaćanje kataloga...'; + + @override + String get fifteenMinutes => '15 minuta'; + + @override + String get fifteenSeconds => '15 sekundi'; + + @override + String get filterByDirection => 'Filtriraj po smjeru'; + + @override + String get finish => 'Završi'; + + @override + String get fiveMinutes => '5 minuta'; + + @override + String get fiveSeconds => '5 sekundi'; + + @override + String get fix => 'Fiksiranje'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'Pravila GPS oglašavanja'; + + @override + String get gpsAndLocation => 'GPS i lokacija'; + + @override + String get gpsDiagnostics => 'GPS dijagnostika'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Pomoć'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Skriveno'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Kako radio automatski dodaje otkrivene čvorove.'; + + @override + String get images => 'Slike'; + + @override + String get importProfile => 'Uvezi profil'; + + @override + String get lastFix => 'Zadnje fiksiranje'; + + @override + String get latest => 'Najnovije'; + + @override + String get latitude => 'Geografska širina'; + + @override + String get loadASavedRegion => 'Učitaj spremljenu regiju'; + + @override + String get loading => 'Učitavanje'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Dijeljenje lokacije, hardver i interval ažuriranja.'; + + @override + String get locationTelemetry => 'Telemetrija lokacije'; + + @override + String get lockDestination => 'Zaključaj odredište'; + + @override + String get longitude => 'Geografska dužina'; + + @override + String get mapStyle => 'Stil karte'; + + @override + String get max => 'Maks'; + + @override + String get maxZoom => 'Maks. zumiranje'; + + @override + String get messaging => 'Poruke'; + + @override + String get metHistory => 'MET povijest'; + + @override + String get min => 'Min'; + + @override + String get minZoom => 'Min. zumiranje'; + + @override + String get moveDown => 'Pomakni dolje'; + + @override + String get moveUp => 'Pomakni gore'; + + @override + String get multiAckMode => 'Multi-ACK način'; + + @override + String get multiDeviceWorkspaceManagement => + 'Upravljanje radnim prostorom s više uređaja'; + + @override + String get myLocation => 'Moja lokacija'; + + @override + String get nameAndTelemetryShared => + 'Ime i telemetrija dijeljeni s drugim uređajima.'; + + @override + String newVersionAvailable(String version) { + return 'Nova verzija: $version'; + } + + @override + String get newer => 'Novije'; + + @override + String get noNeighboursFound => 'Nisu pronađeni susjedi'; + + @override + String get noServersFound => 'Nisu pronađeni poslužitelji'; + + @override + String get notSet => 'Nije postavljeno'; + + @override + String get notifications => 'Obavijesti'; + + @override + String get offZeroSeconds => 'Isključeno (0 s)'; + + @override + String get offlineMaps => 'Offline karte'; + + @override + String get older => 'Starije'; + + @override + String get oneByteMode0 => '1 bajt (način 0)'; + + @override + String get oneHour => '1 sat'; + + @override + String get oneMinute => '1 minuta'; + + @override + String get openPacketLogs => 'Otvori zapise paketa'; + + @override + String get packetTypeHelp => 'Pomoć za vrstu paketa'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Zalijepite dijeljenu vezu ili Hex oglas'; + + @override + String get pathHash => 'Hash putanje'; + + @override + String get pathSize => 'Veličina putanje'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Poligon'; + + @override + String get rawResponseCopied => 'Sirovi odgovor kopiran'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Ponovno pošalji kroz najbliži posrednik pri neuspjehu'; + + @override + String get resolveContact => 'Razriješi kontakt'; + + @override + String get routingRetriesAndDestinationLock => + 'Usmjeravanje, pokušaji i zaključavanje odredišta'; + + @override + String get samples => 'Uzorci'; + + @override + String get satellites => 'Sateliti'; + + @override + String get saving => 'Spremanje...'; + + @override + String get search => 'Pretraži'; + + @override + String get searchByNameOrToken => 'Pretraži po imenu ili tokenu'; + + @override + String get searchDiscoveredNodes => 'Pretraži otkrivene čvorove'; + + @override + String get searchInCurrentFilter => 'Pretraži u trenutnom filtru'; + + @override + String get searchLogs => 'Pretraži zapise...'; + + @override + String get searchSensors => 'Pretraži senzore'; + + @override + String get searchThisSection => 'Pretraži ovaj odjeljak'; + + @override + String get selectedRelays => 'Odabrani posrednici'; + + @override + String get sensor => 'Senzor'; + + @override + String serialError(String error) { + return 'Serijska greška: $error'; + } + + @override + String setPathForContact(String name) { + return 'Postavi putanju za $name'; + } + + @override + String get shareLiveGps => 'Dijeli GPS uživo'; + + @override + String get shareMyTiles => 'Dijeli moje pločice'; + + @override + String get sharingLocationFromPhone => + 'Dijeljenje lokacije na ovom kanalu s telefona.'; + + @override + String get sharingLocationFromRadio => + 'Dijeljenje lokacije na ovom kanalu s radija.'; + + @override + String signalDbm(String rssi) { + return 'Signal $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => 'Veličina, kompresija i pregled'; + + @override + String get simpleMode => 'Jednostavni način'; + + @override + String get simpleModeDescription => + 'Samo razgovor i karta s omiljenim kontaktima'; + + @override + String get simpleModeRequiresLockedDestination => + 'Za korištenje jednostavnog načina najprije omogućite „Zaključaj odredište” s kanalom'; + + @override + String get noFavouriteContacts => 'Nema omiljenih kontakata'; + + @override + String get skip => 'Preskoči'; + + @override + String get smallerFileSize => 'Manja veličina datoteke'; + + @override + String get sort => 'Sortiraj'; + + @override + String get stoppedSharingLocation => + 'Zaustavljeno dijeljenje lokacije na ovom kanalu.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Utiši obavijesti dok je aplikacija u prvom planu'; + + @override + String get tabsAndNavigation => 'Kartice i navigacija'; + + @override + String get templatesAndTutorials => 'Predlošci i upute'; + + @override + String get tenMinutes => '10 minuta'; + + @override + String get tenSeconds => '10 sekundi'; + + @override + String get testSendUpdate => 'Testno slanje ažuriranja'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Tema, jezik i postavke prikaza'; + + @override + String get thirtyMinutes => '30 minuta'; + + @override + String get thirtySeconds => '30 sekundi'; + + @override + String get threeBytesMode2 => '3 bajta (način 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Statistika prometa, povijest poruka i razvojni alati'; + + @override + String get twoBytesMode1 => '2 bajta (način 1)'; + + @override + String get twoMinutes => '2 minute'; + + @override + String get undo => 'Poništi'; + + @override + String get updated => 'Ažurirano'; + + @override + String get useContactFlags => 'Koristi zastavice kontakata'; + + @override + String get useSavedCoordinates => 'Koristi spremljene koordinate'; + + @override + String get view => 'Prikaži'; + + @override + String get viewNeighbours => 'Prikaži susjede'; + + @override + String get viewPublicStats => 'Prikaži javnu statistiku'; + + @override + String get setRegionScope => 'Postavi opseg regije'; + + @override + String get regionScope => 'Opseg regije'; + + @override + String get regionScopeNone => 'Nema (globalno)'; + + @override + String get clearRegionScope => 'Obriši opseg'; + + @override + String get regionScopeWarning => + 'Samo repetitori koji dopuštaju ovu regiju će prosljeđivati.'; + + @override + String get discoverRegions => 'Otkrij iz repetitora'; + + @override + String get discoveringRegions => 'Otkrivanje regija...'; + + @override + String get enterRegionName => 'Naziv regije (npr. auckland)'; + + @override + String get noRegionsFound => 'Nema pronađenih regija na ovom repetitoru.'; + + @override + String regionScopeSet(String name) { + return 'Opseg regije postavljen na $name'; + } + + @override + String get regionScopeCleared => 'Opseg regije obrisan'; + + @override + String get selectDestinationFirst => + 'Najprije odaberite kanal, kontakt ili sobu'; + + @override + String get recipientNoLongerAvailable => 'Primatelj više nije dostupan'; + + @override + String failedToSendMessage(String error) { + return 'Slanje nije uspjelo: $error'; + } + + @override + String get tictactoeDirectOnly => + 'Križić-kružić radi samo u izravnim porukama. Najprije odaberite kontakt.'; + + @override + String get deviceKeyUnavailable => 'Ključ uređaja nije dostupan'; + + @override + String get imageCompressionFailed => 'Komprimiranje slike nije uspjelo'; + + @override + String get imageFragmentationFailed => 'Fragmentiranje slike nije uspjelo'; + + @override + String get failedToAnnounceImage => 'Najava slike nije uspjela'; + + @override + String get noRecipientSelected => 'Primatelj nije odabran'; + + @override + String get imageSendFailed => 'Slanje slike nije uspjelo'; + + @override + String get microphonePermissionRequiredForVoice => + 'Za glasovne poruke potrebna je dozvola za mikrofon'; + + @override + String get recordingDiscarded => 'Snimka je odbačena'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Upravo ćete poslati $mediaType na javni kanal. To se ne preporučuje jer sadržaj mogu primiti svi na mesh mreži. Odaberite privatni ili označeni kanal, osim ako je to baš ono što želite.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Odaberite privatni kanal za dijeljenje lokacije.'; + + @override + String get stopSharingMyLocation => 'Prestani dijeliti moju lokaciju'; + + @override + String get shareMyLocation => 'Dijeli moju lokaciju'; + + @override + String get stopRecording => 'Zaustavi snimanje'; + + @override + String get recordVoice => 'Snimi glasovnu poruku'; + + @override + String get moreActions => 'Više radnji'; + + @override + String get noMessagesInFilter => 'Nema poruka u ovom filtru'; + + @override + String get noMatchesInFilter => 'Nema rezultata u ovom filtru'; + + @override + String syncFailed(String error) { + return 'Sinkronizacija nije uspjela: $error'; + } + + @override + String get invalidLink => 'Nevažeća poveznica'; + + @override + String get cannotOpenLink => 'Poveznicu nije moguće otvoriti'; + + @override + String get failedToOpenLink => 'Otvaranje poveznice nije uspjelo'; + + @override + String get failedToResendMessage => 'Ponovno slanje poruke nije uspjelo'; + + @override + String retryFailed(String error) { + return 'Ponovni pokušaj nije uspio: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Izbriši crtež i poruku'; + + @override + String get lastEchoRelay => 'Zadnji odjek – relej'; + + @override + String get lastEchoPath => 'Zadnji odjek – putanja'; + + @override + String get lastEchoBytesReport => 'Zadnji odjek – izvještaj o bajtovima'; + + @override + String get noGpsCoordinatesAvailable => 'GPS koordinate nisu dostupne'; + + @override + String get notASarMarker => 'Nije SAR marker'; + + @override + String failedToSaveTemplate(String error) { + return 'Spremanje predloška nije uspjelo: $error'; + } + + @override + String get drawingNotFound => 'Crtež nije pronađen'; + + @override + String get unknownDrawingType => 'Nepoznata vrsta crteža'; + + @override + String get customMapMarker => 'Prilagođeni marker karte'; + + @override + String customMapPointLabel(String point) { + return 'Točka: $point'; + } + + @override + String mapIdLabel(String id) { + return 'ID karte: $id'; + } + + @override + String get cannotFetchVoice => 'Glasovnu poruku nije moguće dohvatiti'; + + @override + String get senderContactUnknown => + 'Kontakt pošiljatelja nije poznat. Najprije sinkronizirajte kontakte.'; + + @override + String get senderRouteUnknown => + 'Ruta pošiljatelja nije poznata. Najprije sinkronizirajte kontakte/putanju.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Poruka je predaleko ($hops skokova, najviše $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'Ruta pošiljatelja nije odgovorila na provjeru putanje. Sinkronizirajte kontakte/putanju i pokušajte ponovno.'; + + @override + String get senderRouteNoRawResponse => + 'Ruta pošiljatelja nije odgovorila na izravnoj prijenosnoj putanji.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Dohvaćanje glasa preko $hops skokova može potrajati.'; + } + + @override + String get voiceReceiveCanceled => 'Primanje glasa je otkazano'; + + @override + String get fetchingMissingVoiceFragments => + 'Dohvaćanje nedostajućih glasovnih fragmenata'; + + @override + String get receivingVoice => 'Primanje glasa'; + + @override + String get cannotFetchImage => 'Sliku nije moguće dohvatiti'; + + @override + String imageFetchOverHops(String hops) { + return 'Dohvaćanje slike preko $hops skokova može potrajati.'; + } + + @override + String get imageAlreadyBeingReceived => 'Slika se već prima'; + + @override + String get loadImage => 'Učitaj sliku'; + + @override + String get imageFetchFailedToSendRequest => + 'Zahtjev za dohvaćanje slike nije poslan'; + + @override + String get imageUnavailable => 'Slika trenutačno nije dostupna'; + + @override + String get imageFetchTimedOut => 'Dohvaćanje slike je isteklo'; + + @override + String get imageReceiveCanceled => 'Primanje slike je otkazano'; + + @override + String get closeImagePreview => 'Zatvori pregled slike'; + + @override + String get recordingReleaseToSend => 'Snimanje... otpustite za slanje glasa'; + + @override + String get sendingVoice => 'Slanje glasa...'; + + @override + String get sendLongPressToRecordVoice => + 'Pošalji (dugi pritisak za snimanje glasa)'; + + @override + String get discardRecording => 'Odbaci snimku'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count skokova', + few: '$count skoka', + one: '$count skok', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Kanali za emitiranje mesh prometa u blizini'; + + @override + String get roomsSectionDescription => + 'Zajednički prostori za stalnu koordinaciju tima'; + + @override + String get contactsSectionDescription => + 'Osobe i uređaji koje možete izravno dosegnuti'; + + @override + String get noRecentChatters => 'Nema nedavnih sudionika'; + + @override + String get public => 'Javno'; + + @override + String get failedToSendTicTacToeMove => 'Potez križić-kružić nije poslan'; + + @override + String get youWon => 'Pobijedili ste'; + + @override + String get opponentWon => 'Protivnik je pobijedio'; + + @override + String get gameDraw => 'Neriješeno'; + + @override + String get yourTurn => 'Vi ste na potezu'; + + @override + String get opponentTurn => 'Protivnik je na potezu'; + + @override + String get pleaseEnterValidCoordinates => 'Unesite valjane koordinate'; + + @override + String get locationNotAvailable => 'Lokacija nije dostupna'; + + @override + String get customCaveMapPoint => 'Prilagođena točka karte špilje'; + + @override + String get mapPoint => 'Točka karte'; + + @override + String get fetchingMissingImageFragments => + 'Dohvaćanje nedostajućih fragmenata…'; + + @override + String get loadingImage => 'Učitavanje…'; + + @override + String get receivingImage => 'Primanje…'; + + @override + String get tapToLoad => 'Dodirnite za učitavanje'; + + @override + String get connectDevice => 'Poveži uređaj'; + + @override + String get chooseTransportSubtitle => + 'Odaberite način povezivanja: Bluetooth, WiFi ili serijski'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Mreža'; + + @override + String get serial => 'Serijski'; + + @override + String get scan => 'Skeniraj'; + + @override + String get pressScanToSearchForDevices => + 'Pritisnite Skeniraj za traženje uređaja u blizini'; + + @override + String failedToConnectToDevice(String name) { + return 'Povezivanje s uređajem $name nije uspjelo'; + } + + @override + String get unknownDevice => 'Nepoznat uređaj'; + + @override + String get showingCachedResultsTapRefresh => + 'Prikazani su predmemorirani rezultati. Dodirnite osvježi za ponovno skeniranje.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Skeniranje lokalne mreže za MeshCore WiFi uređaje na portu $port'; + } + + @override + String get cancelScanAndAddServer => 'Otkaži skeniranje i dodaj poslužitelj'; + + @override + String get addServer => 'Dodaj poslužitelj'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Skeniranje... $scanned/$total IP adresa'; + } + + @override + String get cancelAndEnterManually => 'Otkaži i unesi ručno'; + + @override + String get noRecentOrDiscoveredServers => + 'Još nema nedavnih ili otkrivenih poslužitelja'; + + @override + String get recentlyUsed => 'Nedavno korišteno'; + + @override + String get connecting => 'Povezivanje...'; + + @override + String get discoveredOnThisNetwork => 'Otkriveno na ovoj mreži'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'Poslužitelj na $host:$port više nije dostupan. Skenirajte ponovno da pronađete aktivne poslužitelje.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Povezivanje s $host:$port nije uspjelo'; + } + + @override + String get enterValidIpAddress => 'Unesite valjanu IP adresu'; + + @override + String get enterValidTcpPort => 'Unesite valjani TCP port'; + + @override + String get ipAddress => 'IP adresa'; + + @override + String get tcpPort => 'TCP port'; + + @override + String get customServerPort => 'Prilagođeni port poslužitelja'; + + @override + String get chat => 'Razgovor'; + + @override + String get restoringPreviousLink => 'Obnavljanje prethodne veze'; + + @override + String get noDeviceConnected => 'Nijedan uređaj nije povezan'; + + @override + String get sendAdvert => 'Pošalji moj kontakt'; + + @override + String get advertMode => 'Pošalji moj kontakt'; + + @override + String get advertModeSubtitle => + 'Odaberite koliko daleko trebaju putovati vaši kontaktni podaci.'; + + @override + String get floodAdvertSent => 'Kontakt poslan preplavljanjem kroz mrežu'; + + @override + String get directAdvertSent => 'Kontakt poslan izravno obližnjim uređajima'; +} diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart new file mode 100644 index 0000000..75394cb --- /dev/null +++ b/lib/l10n/app_localizations_it.dart @@ -0,0 +1,3454 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Italian (`it`). +class AppLocalizationsIt extends AppLocalizations { + AppLocalizationsIt([String locale = 'it']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Messaggi'; + + @override + String get contacts => 'Contatti'; + + @override + String get map => 'Mappa'; + + @override + String get settings => 'Impostazioni'; + + @override + String get connect => 'Connetti'; + + @override + String get disconnect => 'Disconnetti'; + + @override + String get noDevicesFound => 'Nessun dispositivo trovato'; + + @override + String get scanAgain => 'Cerca Nuovamente'; + + @override + String get deviceNotConnected => 'Dispositivo non connesso'; + + @override + String get locationPermissionDenied => 'Autorizzazione posizione negata'; + + @override + String get locationPermissionPermanentlyDenied => + 'Autorizzazione posizione negata permanentemente. Abilitarla nelle Impostazioni.'; + + @override + String get locationPermissionRequired => + 'L\'autorizzazione alla posizione è necessaria per il tracciamento GPS e il coordinamento del team. Puoi abilitarla successivamente nelle Impostazioni.'; + + @override + String get locationServicesDisabled => + 'I servizi di localizzazione sono disabilitati. Abilitarli nelle Impostazioni.'; + + @override + String get failedToGetGpsLocation => 'Impossibile ottenere la posizione GPS'; + + @override + String failedToAdvertise(String error) { + return 'Annuncio fallito: $error'; + } + + @override + String get cancelReconnection => 'Annulla riconnessione'; + + @override + String get general => 'Generale'; + + @override + String get theme => 'Tema'; + + @override + String get chooseTheme => 'Scegli Tema'; + + @override + String get light => 'Chiaro'; + + @override + String get dark => 'Scuro'; + + @override + String get blueLightTheme => 'Tema blu chiaro'; + + @override + String get blueDarkTheme => 'Tema blu scuro'; + + @override + String get sarRed => 'SAR Rosso'; + + @override + String get alertEmergencyMode => 'Modalità Allerta/Emergenza'; + + @override + String get sarGreen => 'SAR Verde'; + + @override + String get safeAllClearMode => 'Modalità Sicuro/Tutto Libero'; + + @override + String get autoSystem => 'Auto (Sistema)'; + + @override + String get followSystemTheme => 'Segui tema di sistema'; + + @override + String get showRxTxIndicators => 'Mostra Indicatori RX/TX'; + + @override + String get disableMap => 'Disabilita mappa'; + + @override + String get language => 'Lingua'; + + @override + String get chooseLanguage => 'Scegli Lingua'; + + @override + String get save => 'Salva'; + + @override + String get cancel => 'Annulla'; + + @override + String get close => 'Chiudi'; + + @override + String get about => 'Informazioni'; + + @override + String get appVersion => 'Versione App'; + + @override + String get aboutMeshCoreSar => 'Informazioni su MeshCore SAR'; + + @override + String get aboutDescription => + 'Un\'applicazione di Ricerca e Soccorso progettata per i team di emergenza. Le caratteristiche includono:\n\n• Rete mesh BLE per comunicazione dispositivo-a-dispositivo\n• Mappe offline con opzioni di livelli multipli\n• Tracciamento in tempo reale dei membri del team\n• Marcatori tattici SAR (persona trovata, incendio, area di appoggio)\n• Gestione contatti e messaggistica\n• Tracciamento GPS con direzione bussola\n• Caching dei tile della mappa per uso offline'; + + @override + String get technologiesUsed => 'Tecnologie Utilizzate:'; + + @override + String get technologiesList => + '• Flutter per lo sviluppo multipiattaforma\n• BLE (Bluetooth Low Energy) per la rete mesh\n• OpenStreetMap per la mappatura\n• Provider per la gestione dello stato\n• SharedPreferences per l\'archiviazione locale'; + + @override + String get moreInfo => 'Maggiori informazioni'; + + @override + String get packageName => 'Nome Pacchetto'; + + @override + String get sampleData => 'Dati di Esempio'; + + @override + String get sampleDataDescription => + 'Carica o cancella contatti di esempio, messaggi di canale e marcatori SAR per test'; + + @override + String get loadSampleData => 'Carica Dati di Esempio'; + + @override + String get clearAllData => 'Cancella Tutti i Dati'; + + @override + String get clearAllDataConfirmTitle => 'Cancella Tutti i Dati'; + + @override + String get clearAllDataConfirmMessage => + 'Questo cancellerà tutti i contatti e i marcatori SAR. Sei sicuro?'; + + @override + String get clear => 'Cancella'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Caricati $teamCount membri del team, $channelCount canali, $sarCount marcatori SAR, $messageCount messaggi'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Impossibile caricare dati di esempio: $error'; + } + + @override + String get allDataCleared => 'Tutti i dati cancellati'; + + @override + String get failedToStartBackgroundTracking => + 'Impossibile avviare il tracciamento in background. Verifica le autorizzazioni e la connessione BLE.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Trasmissione posizione: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'Il PIN predefinito per i dispositivi senza schermo è 123456. Problemi di accoppiamento? Dimentica il dispositivo bluetooth nelle impostazioni di sistema.'; + + @override + String get noMessagesYet => 'Nessun messaggio ancora'; + + @override + String get pullDownToSync => + 'Trascina verso il basso per sincronizzare i messaggi'; + + @override + String get deleteContact => 'Elimina Contatto'; + + @override + String get delete => 'Elimina'; + + @override + String get viewOnMap => 'Visualizza su Mappa'; + + @override + String get refresh => 'Aggiorna'; + + @override + String get resetPath => 'Resetta Percorso (Ri-instrada)'; + + @override + String copiedToClipboard(String label) { + return '$label copiato negli appunti'; + } + + @override + String get pleaseEnterPassword => 'Inserisci una password'; + + @override + String failedToSyncContacts(String error) { + return 'Impossibile sincronizzare i contatti: $error'; + } + + @override + String get loggedInSuccessfully => + 'Accesso effettuato con successo! In attesa dei messaggi della stanza...'; + + @override + String get loginFailed => 'Accesso fallito - password errata'; + + @override + String loggingIn(String roomName) { + return 'Accesso a $roomName in corso...'; + } + + @override + String failedToSendLogin(String error) { + return 'Impossibile inviare l\'accesso: $error'; + } + + @override + String get lowLocationAccuracy => 'Precisione Posizione Bassa'; + + @override + String get continue_ => 'Continua'; + + @override + String get sendSarMarker => 'Invia marcatore SAR'; + + @override + String get deleteDrawing => 'Elimina Disegno'; + + @override + String get drawingTools => 'Strumenti di Disegno'; + + @override + String get drawLine => 'Disegna Linea'; + + @override + String get drawLineDesc => 'Disegna una linea a mano libera sulla mappa'; + + @override + String get drawRectangle => 'Disegna Rettangolo'; + + @override + String get drawRectangleDesc => 'Disegna un\'area rettangolare sulla mappa'; + + @override + String get measureDistance => 'Misura Distanza'; + + @override + String get measureDistanceDesc => 'Premi a lungo su due punti per misurare'; + + @override + String get clearMeasurement => 'Cancella Misurazione'; + + @override + String distanceLabel(String distance) { + return 'Distanza: $distance'; + } + + @override + String get longPressForSecondPoint => 'Premi a lungo per il secondo punto'; + + @override + String get longPressToStartMeasurement => + 'Premi a lungo per impostare il primo punto'; + + @override + String get longPressToStartNewMeasurement => + 'Premi a lungo per nuova misurazione'; + + @override + String get shareDrawings => 'Condividi Disegni'; + + @override + String get clearAllDrawings => 'Cancella Tutti i Disegni'; + + @override + String get completeLine => 'Completa Linea'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Trasmetti $count disegno$plural alla squadra'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Rimuovi tutti i $count disegno$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Eliminare tutti i $count disegno$plural dalla mappa?'; + } + + @override + String get drawing => 'Disegno'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Condividi $count disegno$plural'; + } + + @override + String get showReceivedDrawings => 'Mostra Disegni Ricevuti'; + + @override + String get showingAllDrawings => 'Visualizzazione di tutti i disegni'; + + @override + String get showingOnlyYourDrawings => 'Visualizzazione solo dei tuoi disegni'; + + @override + String get showSarMarkers => 'Mostra marcatori SAR'; + + @override + String get showingSarMarkers => 'Visualizzazione marcatori SAR'; + + @override + String get hidingSarMarkers => 'Nascondere marcatori SAR'; + + @override + String get clearAll => 'Cancella Tutto'; + + @override + String get publicChannel => 'Canale Pubblico'; + + @override + String get broadcastToAll => 'Trasmetti a tutti i nodi vicini (effimero)'; + + @override + String get storedPermanently => 'Archiviato permanentemente nella stanza'; + + @override + String get notConnectedToDevice => 'Non connesso al dispositivo'; + + @override + String get typeYourMessage => 'Scrivi il tuo messaggio...'; + + @override + String get quickLocationMarker => 'Marcatore posizione rapido'; + + @override + String get markerType => 'Tipo Marcatore'; + + @override + String get sendTo => 'Invia A'; + + @override + String get noDestinationsAvailable => 'Nessuna destinazione disponibile.'; + + @override + String get selectDestination => 'Seleziona destinazione...'; + + @override + String get ephemeralBroadcastInfo => + 'Effimero: Trasmissione via etere solamente. Non archiviato - i nodi devono essere online.'; + + @override + String get persistentRoomInfo => + 'Persistente: Archiviato in modo immutabile nella stanza. Sincronizzato automaticamente e conservato offline.'; + + @override + String get location => 'Posizione'; + + @override + String get fromMap => 'Dalla Mappa'; + + @override + String get gettingLocation => 'Ottenimento posizione...'; + + @override + String get locationError => 'Errore Posizione'; + + @override + String get retry => 'Riprova'; + + @override + String get refreshLocation => 'Aggiorna posizione'; + + @override + String accuracyMeters(int accuracy) { + return 'Precisione: ±${accuracy}m'; + } + + @override + String get notesOptional => 'Note (facoltativo)'; + + @override + String get addAdditionalInformation => 'Aggiungi informazioni aggiuntive...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'La precisione della posizione è ±${accuracy}m. Potrebbe non essere abbastanza accurata per le operazioni SAR.\n\nContinuare comunque?'; + } + + @override + String get loginToRoom => 'Accedi alla Stanza'; + + @override + String get enterPasswordInfo => + 'Inserisci la password per accedere a questa stanza. La password verrà salvata per usi futuri.'; + + @override + String get password => 'Password'; + + @override + String get enterRoomPassword => 'Inserisci password stanza'; + + @override + String get loggingInDots => 'Accesso in corso...'; + + @override + String get login => 'Accedi'; + + @override + String failedToAddRoom(String error) { + return 'Impossibile aggiungere la stanza al dispositivo: $error\n\nLa stanza potrebbe non aver ancora trasmesso.\nProva ad attendere che la stanza trasmetta.'; + } + + @override + String get direct => 'Diretto'; + + @override + String get flood => 'Flood'; + + @override + String get autoSend => 'Invio automatico'; + + @override + String get autoSendDescription => 'Invia tramite il percorso attuale.'; + + @override + String get sendDirect => 'Invio diretto'; + + @override + String get sendDirectDescription => 'Invia direttamente a questo contatto.'; + + @override + String get sendFlood => 'Invio flood'; + + @override + String get sendFloodDescription => 'Invia tramite tutti i ripetitori.'; + + @override + String get loggedIn => 'Connesso'; + + @override + String get noGpsData => 'Nessun dato GPS'; + + @override + String get distance => 'Distanza'; + + @override + String directPingTimeout(String name) { + return 'Timeout ping diretto - nuovo tentativo $name con flooding...'; + } + + @override + String pingFailed(String name) { + return 'Ping fallito a $name - nessuna risposta ricevuta'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Sei sicuro di voler eliminare \"$name\"?\n\nQuesto rimuoverà il contatto sia dall\'app che dal dispositivo radio companion.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Impossibile rimuovere il contatto: $error'; + } + + @override + String get type => 'Tipo'; + + @override + String get publicKey => 'Chiave Pubblica'; + + @override + String get lastSeen => 'Ultima Visita'; + + @override + String get yes => 'Sì'; + + @override + String get no => 'No'; + + @override + String get permissions => 'Permessi'; + + @override + String get telemetry => 'Telemetria'; + + @override + String get voltage => 'Tensione'; + + @override + String get battery => 'Batteria'; + + @override + String get temperature => 'Temperatura'; + + @override + String get humidity => 'Umidità'; + + @override + String get pressure => 'Pressione'; + + @override + String get gpsTelemetry => 'GPS (Telemetria)'; + + @override + String get reLoginToRoom => 'Riaccedi alla Stanza'; + + @override + String get heading => 'Direzione'; + + @override + String get elevation => 'Elevazione'; + + @override + String get accuracy => 'Precisione'; + + @override + String get bearing => 'Rilevamento'; + + @override + String get direction => 'Direzione'; + + @override + String get filterMarkers => 'Filtra Marcatori'; + + @override + String get filterMarkersTooltip => 'Filtra marcatori'; + + @override + String get contactsFilter => 'Contatti'; + + @override + String get repeatersFilter => 'Ripetitori'; + + @override + String get sarMarkers => 'Marcatori SAR'; + + @override + String get foundPerson => 'Persona Trovata'; + + @override + String get fire => 'Incendio'; + + @override + String get stagingArea => 'Area di Appoggio'; + + @override + String get showAll => 'Mostra Tutto'; + + @override + String get locationUnavailable => 'Posizione non disponibile'; + + @override + String get ahead => 'avanti'; + + @override + String degreesRight(int degrees) { + return '$degrees° destra'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° sinistra'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Lat: $latitude Lon: $longitude'; + } + + @override + String get noContactsYet => 'Nessun contatto ancora'; + + @override + String get connectToDeviceToLoadContacts => + 'Connetti a un dispositivo per caricare i contatti'; + + @override + String get teamMembers => 'Membri del Team'; + + @override + String get repeaters => 'Ripetitori'; + + @override + String get rooms => 'Stanze'; + + @override + String get channels => 'Canali'; + + @override + String get selectMapLayer => 'Seleziona Livello Mappa'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'ESRI Satellite'; + + @override + String get googleHybrid => 'Google Ibrido'; + + @override + String get googleRoadmap => 'Google Mappa Stradale'; + + @override + String get googleTerrain => 'Google Terreno'; + + @override + String get dragToPosition => 'Trascina per Posizionare'; + + @override + String get createSarMarker => 'Crea Marcatore SAR'; + + @override + String get compass => 'Bussola'; + + @override + String get navigationAndContacts => 'Navigazione e Contatti'; + + @override + String get sarAlert => 'ALLERTA SAR'; + + @override + String get textCopiedToClipboard => 'Testo copiato negli appunti'; + + @override + String get cannotReplySenderMissing => + 'Impossibile rispondere: informazioni mittente mancanti'; + + @override + String get cannotReplyContactNotFound => + 'Impossibile rispondere: contatto non trovato'; + + @override + String get copyText => 'Copia testo'; + + @override + String get saveAsTemplate => 'Salva come Modello'; + + @override + String get templateSaved => 'Modello salvato con successo'; + + @override + String get templateAlreadyExists => 'Esiste già un modello con questa emoji'; + + @override + String get deleteMessage => 'Elimina messaggio'; + + @override + String get deleteMessageConfirmation => + 'Sei sicuro di voler eliminare questo messaggio?'; + + @override + String get shareLocation => 'Condividi posizione'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nCoordinate: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'Posizione SAR'; + + @override + String get justNow => 'Proprio ora'; + + @override + String minutesAgo(int minutes) { + return '${minutes}m fa'; + } + + @override + String hoursAgo(int hours) { + return '${hours}h fa'; + } + + @override + String daysAgo(int days) { + return '${days}g fa'; + } + + @override + String secondsAgo(int seconds) { + return '${seconds}s fa'; + } + + @override + String get sending => 'Invio...'; + + @override + String get sent => 'Inviato'; + + @override + String get delivered => 'Consegnato'; + + @override + String deliveredWithTime(int time) { + return 'Consegnato (${time}ms)'; + } + + @override + String get failed => 'Fallito'; + + @override + String get broadcast => 'Trasmissione'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Consegnato a $delivered/$total contatti'; + } + + @override + String get allDelivered => 'Tutto consegnato'; + + @override + String get recipientDetails => 'Dettagli destinatari'; + + @override + String get pending => 'In attesa'; + + @override + String get sarMarkerFoundPerson => 'Persona Trovata'; + + @override + String get sarMarkerFire => 'Posizione Incendio'; + + @override + String get sarMarkerStagingArea => 'Area di Appoggio'; + + @override + String get sarMarkerObject => 'Oggetto Trovato'; + + @override + String get from => 'Da'; + + @override + String get coordinates => 'Coordinate'; + + @override + String get tapToViewOnMap => 'Tocca per visualizzare sulla mappa'; + + @override + String get radioSettings => 'Impostazioni Radio'; + + @override + String get frequencyMHz => 'Frequenza (MHz)'; + + @override + String get bandwidth => 'Larghezza di Banda'; + + @override + String get spreadingFactor => 'Fattore di Spreading'; + + @override + String get codingRate => 'Tasso di Codifica'; + + @override + String get txPowerDbm => 'Potenza TX (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Max: $power dBm'; + } + + @override + String get you => 'Tu'; + + @override + String exportFailed(String error) { + return 'Esportazione non riuscita: $error'; + } + + @override + String importFailed(String error) { + return 'Importazione non riuscita: $error'; + } + + @override + String get unknown => 'Sconosciuto'; + + @override + String get onlineLayers => 'Livelli Online'; + + @override + String get locationTrail => 'Traccia Posizione'; + + @override + String get showTrailOnMap => 'Mostra Traccia sulla Mappa'; + + @override + String get trailVisible => 'La traccia è visibile sulla mappa'; + + @override + String get trailHiddenRecording => + 'La traccia è nascosta (ancora in registrazione)'; + + @override + String get duration => 'Durata'; + + @override + String get points => 'Punti'; + + @override + String get clearTrail => 'Cancella Traccia'; + + @override + String get clearTrailQuestion => 'Cancellare Traccia?'; + + @override + String get clearTrailConfirmation => + 'Sei sicuro di voler cancellare la traccia posizione attuale? Questa azione non può essere annullata.'; + + @override + String get noTrailRecorded => 'Nessuna traccia registrata ancora'; + + @override + String get startTrackingToRecord => + 'Avvia il tracciamento posizione per registrare la tua traccia'; + + @override + String get trailControls => 'Controlli Traccia'; + + @override + String get model => 'Modello'; + + @override + String get version => 'Versione'; + + @override + String get maxContacts => 'Contatti Max'; + + @override + String get maxChannels => 'Canali Max'; + + @override + String get publicInfo => 'Informazioni Pubbliche'; + + @override + String get useCurrentLocation => 'Usa posizione attuale'; + + @override + String get noneUnknown => 'Nessuno/Sconosciuto'; + + @override + String get chatNode => 'Nodo Chat'; + + @override + String get repeater => 'Ripetitore'; + + @override + String get roomChannel => 'Stanza/Canale'; + + @override + String typeNumber(int number) { + return 'Tipo $number'; + } + + @override + String failedToSave(String error) { + return 'Impossibile salvare: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Impossibile ottenere la posizione: $error'; + } + + @override + String get sarTemplates => 'Modelli SAR'; + + @override + String get addTemplate => 'Aggiungi modello'; + + @override + String get editTemplate => 'Modifica modello'; + + @override + String get deleteTemplate => 'Elimina modello'; + + @override + String get templateName => 'Nome modello'; + + @override + String get templateNameHint => 'ad es. Persona trovata'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji è obbligatorio'; + + @override + String get nameRequired => 'Nome è obbligatorio'; + + @override + String get templateDescription => 'Descrizione (facoltativa)'; + + @override + String get templateDescriptionHint => 'Aggiungi contesto aggiuntivo...'; + + @override + String get templateColor => 'Colore'; + + @override + String get previewFormat => 'Anteprima (formato messaggio SAR)'; + + @override + String get importFromClipboard => 'Importa'; + + @override + String get exportToClipboard => 'Esporta'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Eliminare il modello \'$name\'?'; + } + + @override + String get templateAdded => 'Modello aggiunto'; + + @override + String get templateUpdated => 'Modello aggiornato'; + + @override + String get templateDeleted => 'Modello eliminato'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Importati $count modelli', + one: 'Importato 1 modello', + zero: 'Nessun modello importato', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Esportati $count modelli negli appunti', + one: 'Esportato 1 modello negli appunti', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Ripristina predefiniti'; + + @override + String get resetToDefaultsConfirmation => + 'Questo eliminerà tutti i modelli personalizzati e ripristinerà i 4 modelli predefiniti. Continuare?'; + + @override + String get reset => 'Ripristina'; + + @override + String get resetComplete => 'Modelli ripristinati ai valori predefiniti'; + + @override + String get noTemplates => 'Nessun modello disponibile'; + + @override + String get tapAddToCreate => 'Tocca + per creare il tuo primo modello'; + + @override + String get ok => 'OK'; + + @override + String get locationPermission => 'Permesso di posizione'; + + @override + String get checking => 'Verifica in corso...'; + + @override + String get locationPermissionGrantedAlways => 'Concesso (Sempre)'; + + @override + String get locationPermissionGrantedWhileInUse => 'Concesso (Durante l\'uso)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Negato - Tocca per richiedere'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Negato permanentemente - Apri impostazioni'; + + @override + String get locationPermissionDialogContent => + 'Il permesso di posizione è permanentemente negato. Si prega di abilitarlo nelle impostazioni del dispositivo per utilizzare il tracciamento GPS e la condivisione della posizione.'; + + @override + String get openSettings => 'Apri impostazioni'; + + @override + String get locationPermissionGranted => 'Permesso di posizione concesso!'; + + @override + String get locationPermissionRequiredForGps => + 'Il permesso di posizione è necessario per il tracciamento GPS e la condivisione della posizione.'; + + @override + String get locationPermissionAlreadyGranted => + 'Il permesso di posizione è già concesso.'; + + @override + String get sarNavyBlue => 'SAR Blu Navy'; + + @override + String get sarNavyBlueDescription => 'Modalità Professionale/Operativa'; + + @override + String get selectRecipient => 'Seleziona destinatario'; + + @override + String get broadcastToAllNearby => 'Trasmetti a tutti nelle vicinanze'; + + @override + String get searchRecipients => 'Cerca destinatari...'; + + @override + String get noContactsFound => 'Nessun contatto trovato'; + + @override + String get noRoomsFound => 'Nessuna stanza trovata'; + + @override + String get noRecipientsAvailable => 'Nessun destinatario disponibile'; + + @override + String get noChannelsFound => 'Nessun canale trovato'; + + @override + String get newMessage => 'Nuovo messaggio'; + + @override + String get channel => 'Canale'; + + @override + String get samplePoliceLead => 'Capo della Polizia'; + + @override + String get sampleDroneOperator => 'Operatore Drone'; + + @override + String get sampleFirefighterAlpha => 'Vigile del Fuoco'; + + @override + String get sampleMedicCharlie => 'Medico'; + + @override + String get sampleCommandDelta => 'Comando'; + + @override + String get sampleFireEngine => 'Autopompa'; + + @override + String get sampleAirSupport => 'Supporto Aereo'; + + @override + String get sampleBaseCoordinator => 'Coordinatore di Base'; + + @override + String get channelEmergency => 'Emergenza'; + + @override + String get channelCoordination => 'Coordinamento'; + + @override + String get channelUpdates => 'Aggiornamenti'; + + @override + String get sampleTeamMember => 'Membro del Team di Esempio'; + + @override + String get sampleScout => 'Esploratore di Esempio'; + + @override + String get sampleBase => 'Base di Esempio'; + + @override + String get sampleSearcher => 'Cercatore di Esempio'; + + @override + String get sampleObjectBackpack => ' Zaino trovato - colore blu'; + + @override + String get sampleObjectVehicle => + ' Veicolo abbandonato - controllare il proprietario'; + + @override + String get sampleObjectCamping => ' Attrezzatura da campeggio scoperta'; + + @override + String get sampleObjectTrailMarker => ' Segnavia trovato fuori sentiero'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Tutti i team segnalarsi'; + + @override + String get sampleMsgWeatherUpdate => + 'Aggiornamento meteo: Cielo sereno, temp 18°C'; + + @override + String get sampleMsgBaseCamp => 'Campo base stabilito all\'area di raduno'; + + @override + String get sampleMsgTeamAlpha => 'Team si sta spostando al settore 2'; + + @override + String get sampleMsgRadioCheck => + 'Controllo radio - tutte le stazioni rispondano'; + + @override + String get sampleMsgWaterSupply => + 'Rifornimento idrico disponibile al punto di controllo 3'; + + @override + String get sampleMsgTeamBravo => 'Team segnala: settore 1 libero'; + + @override + String get sampleMsgEtaRallyPoint => 'ETA al punto di raduno: 15 minuti'; + + @override + String get sampleMsgSupplyDrop => + 'Lancio rifornimenti confermato per le 14:00'; + + @override + String get sampleMsgDroneSurvey => + 'Sorveglianza con drone completata - nessun ritrovamento'; + + @override + String get sampleMsgTeamCharlie => 'Team richiede rinforzi'; + + @override + String get sampleMsgRadioDiscipline => + 'Tutte le unità: mantenere disciplina radio'; + + @override + String get sampleMsgUrgentMedical => + 'URGENTE: Assistenza medica necessaria al settore 4'; + + @override + String get sampleMsgAdultMale => ' Uomo adulto, cosciente'; + + @override + String get sampleMsgFireSpotted => + 'Incendio avvistato - coordinate in arrivo'; + + @override + String get sampleMsgSpreadingRapidly => ' Si sta diffondendo rapidamente!'; + + @override + String get sampleMsgPriorityHelicopter => + 'PRIORITÀ: Necessario supporto elicottero'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Team medico in rotta verso la vostra posizione'; + + @override + String get sampleMsgEvacHelicopter => + 'Elicottero di evacuazione ETA 10 minuti'; + + @override + String get sampleMsgEmergencyResolved => 'Emergenza risolta - tutto libero'; + + @override + String get sampleMsgEmergencyStagingArea => ' Area di raduno di emergenza'; + + @override + String get sampleMsgEmergencyServices => + 'Servizi di emergenza notificati e in risposta'; + + @override + String get sampleAlphaTeamLead => 'Capo Team'; + + @override + String get sampleBravoScout => 'Esploratore'; + + @override + String get sampleCharlieMedic => 'Medico'; + + @override + String get sampleDeltaNavigator => 'Navigatore'; + + @override + String get sampleEchoSupport => 'Supporto'; + + @override + String get sampleBaseCommand => 'Comando di Base'; + + @override + String get sampleFieldCoordinator => 'Coordinatore sul Campo'; + + @override + String get sampleMedicalTeam => 'Team Medico'; + + @override + String get mapDrawing => 'Disegno della Mappa'; + + @override + String get navigateToDrawing => 'Naviga al Disegno'; + + @override + String get copyCoordinates => 'Copia Coordinate'; + + @override + String get hideFromMap => 'Nascondi dalla Mappa'; + + @override + String get lineDrawing => 'Linea'; + + @override + String get rectangleDrawing => 'Rettangolo'; + + @override + String get manualCoordinates => 'Coordinate Manuali'; + + @override + String get enterCoordinatesManually => 'Inserire le coordinate manualmente'; + + @override + String get latitudeLabel => 'Latitudine'; + + @override + String get longitudeLabel => 'Longitudine'; + + @override + String get exampleCoordinates => 'Esempio: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Condividi Disegno'; + + @override + String get shareWithAllNearbyDevices => + 'Condividi con tutti i dispositivi vicini'; + + @override + String get shareToRoom => 'Condividi nella Stanza'; + + @override + String get sendToPersistentStorage => + 'Invia allo storage persistente della stanza'; + + @override + String get deleteDrawingConfirm => + 'Sei sicuro di voler eliminare questo disegno?'; + + @override + String get drawingDeleted => 'Disegno eliminato'; + + @override + String yourDrawingsCount(int count) { + return 'I Tuoi Disegni ($count)'; + } + + @override + String get shared => 'Condiviso'; + + @override + String get line => 'Linea'; + + @override + String get rectangle => 'Rettangolo'; + + @override + String get updateAvailable => 'Aggiornamento Disponibile'; + + @override + String get currentVersion => 'Attuale'; + + @override + String get latestVersion => 'Ultima'; + + @override + String get downloadUpdate => 'Scarica'; + + @override + String get updateLater => 'Più Tardi'; + + @override + String get cadastralParcels => 'Particelle Catastali'; + + @override + String get forestRoads => 'Strade Forestali'; + + @override + String get wmsOverlays => 'Sovrapposizioni WMS'; + + @override + String get hikingTrails => 'Sentieri Escursionistici'; + + @override + String get mainRoads => 'Strade Principali'; + + @override + String get houseNumbers => 'Numeri Civici'; + + @override + String get fireHazardZones => 'Zone a Rischio Incendio'; + + @override + String get historicalFires => 'Incendi Storici'; + + @override + String get firebreaks => 'Fasce Tagliafuoco'; + + @override + String get krasFireZones => 'Zone di Incendio Kras'; + + @override + String get placeNames => 'Nomi di Luoghi'; + + @override + String get municipalityBorders => 'Confini Comunali'; + + @override + String get topographicMap => 'Carta Topografica 1:25000'; + + @override + String get recentMessages => 'Messaggi Recenti'; + + @override + String get addChannel => 'Aggiungi Canale'; + + @override + String get channelName => 'Nome del Canale'; + + @override + String get channelNameHint => 'es. Squadra di Soccorso Alfa'; + + @override + String get channelSecret => 'Password del Canale'; + + @override + String get channelSecretHint => 'Password condivisa per questo canale'; + + @override + String get channelSecretHelp => + 'Questa password deve essere condivisa con tutti i membri del team che necessitano di accesso a questo canale'; + + @override + String get channelTypesInfo => + 'Canali hash (#squadra): Password generata automaticamente dal nome. Stesso nome = stesso canale su tutti i dispositivi.\n\nCanali privati: Usa una password esplicita. Solo chi ha la password può unirsi.'; + + @override + String get hashChannelInfo => + 'Canale hash: La password verrà generata automaticamente dal nome del canale. Chiunque utilizzi lo stesso nome si unirà allo stesso canale.'; + + @override + String get channelNameRequired => 'Il nome del canale è obbligatorio'; + + @override + String get channelNameTooLong => + 'Il nome del canale deve contenere al massimo 31 caratteri'; + + @override + String get channelSecretRequired => 'La password del canale è obbligatoria'; + + @override + String get channelSecretTooLong => + 'La password del canale deve contenere al massimo 32 caratteri'; + + @override + String get invalidAsciiCharacters => 'Sono consentiti solo caratteri ASCII'; + + @override + String get channelCreatedSuccessfully => 'Canale creato con successo'; + + @override + String channelCreationFailed(String error) { + return 'Creazione del canale fallita: $error'; + } + + @override + String get deleteChannel => 'Elimina Canale'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Sei sicuro di voler eliminare il canale \"$channelName\"? Questa azione non può essere annullata.'; + } + + @override + String get channelDeletedSuccessfully => 'Canale eliminato con successo'; + + @override + String channelDeletionFailed(String error) { + return 'Eliminazione del canale fallita: $error'; + } + + @override + String get createChannel => 'Crea Canale'; + + @override + String get wizardBack => 'Indietro'; + + @override + String get wizardSkip => 'Salta'; + + @override + String get wizardNext => 'Avanti'; + + @override + String get wizardGetStarted => 'Inizia'; + + @override + String get wizardWelcomeTitle => 'Benvenuto in MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Visualizza tutorial di benvenuto'; + + @override + String get allTeamContacts => 'Tutti i contatti del team'; + + @override + String directMessagesInfo(int count) { + return 'Messaggi diretti con conferme. Inviato a $count membri del team.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'Marcatore SAR inviato a $count contatti'; + } + + @override + String get noContactsAvailable => 'Nessun contatto del team disponibile'; + + @override + String get reply => 'Rispondi'; + + @override + String get technicalDetails => 'Dettagli tecnici'; + + @override + String get messageTechnicalDetails => 'Dettagli tecnici del messaggio'; + + @override + String get linkQuality => 'Qualità del collegamento'; + + @override + String get delivery => 'Consegna'; + + @override + String get status => 'Stato'; + + @override + String get expectedAckTag => 'Tag ACK atteso'; + + @override + String get roundTrip => 'Andata e ritorno'; + + @override + String get retryAttempt => 'Tentativo di nuovo invio'; + + @override + String get floodFallback => 'Fallback inondazione'; + + @override + String get identity => 'Identità'; + + @override + String get messageId => 'ID messaggio'; + + @override + String get sender => 'Mittente'; + + @override + String get senderKey => 'Chiave mittente'; + + @override + String get recipient => 'Destinatario'; + + @override + String get recipientKey => 'Chiave destinatario'; + + @override + String get voice => 'Voce'; + + @override + String get voiceId => 'ID vocale'; + + @override + String get envelope => 'Busta'; + + @override + String get sessionProgress => 'Avanzamento sessione'; + + @override + String get complete => 'Completato'; + + @override + String get rawDump => 'Dump grezzo'; + + @override + String get cannotRetryMissingRecipient => + 'Impossibile riprovare: informazioni destinatario mancanti'; + + @override + String get voiceUnavailable => 'Voce non disponibile al momento'; + + @override + String get requestingVoice => 'Richiesta voce'; + + @override + String get device => 'dispositivo'; + + @override + String get change => 'Modifica'; + + @override + String get wizardOverviewDescription => + 'Questa app riunisce messaggistica MeshCore, aggiornamenti SAR sul campo, mappe e strumenti del dispositivo in un unico posto.'; + + @override + String get wizardOverviewFeature1 => + 'Invia messaggi diretti, post nelle stanze e messaggi di canale dalla scheda principale Messaggi.'; + + @override + String get wizardOverviewFeature2 => + 'Condividi marcatori SAR, disegni sulla mappa, clip vocali e immagini sulla mesh.'; + + @override + String get wizardOverviewFeature3 => + 'Connettiti tramite BLE o TCP e poi gestisci la radio companion direttamente dall\'app.'; + + @override + String get wizardMessagingTitle => 'Messaggistica e rapporti sul campo'; + + @override + String get wizardMessagingDescription => + 'Qui i messaggi sono più del semplice testo. L\'app supporta già diversi payload operativi e flussi di trasferimento.'; + + @override + String get wizardMessagingFeature1 => + 'Invia messaggi diretti, post nelle stanze e traffico di canale da un unico compositore.'; + + @override + String get wizardMessagingFeature2 => + 'Crea aggiornamenti SAR e modelli SAR riutilizzabili per i rapporti sul campo più comuni.'; + + @override + String get wizardMessagingFeature3 => + 'Trasferisci sessioni vocali e immagini, con avanzamento e stime del tempo d\'aria nell\'interfaccia.'; + + @override + String get wizardConnectDeviceTitle => 'Connetti dispositivo'; + + @override + String get wizardConnectDeviceDescription => + 'Connetti la tua radio MeshCore, scegli un nome e applica un preset radio prima di continuare.'; + + @override + String get wizardSetupBadge => 'Configurazione'; + + @override + String get wizardOverviewBadge => 'Panoramica'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Connesso a $deviceName'; + } + + @override + String get wizardNoDeviceConnected => 'Nessun dispositivo ancora connesso'; + + @override + String get wizardSkipForNow => 'Salta per ora'; + + @override + String get wizardDeviceNameLabel => 'Nome del dispositivo'; + + @override + String get wizardDeviceNameHelp => + 'Questo nome viene annunciato agli altri utenti MeshCore.'; + + @override + String get wizardConfigRegionLabel => 'Regione di configurazione'; + + @override + String get wizardConfigRegionHelp => + 'Usa l\'elenco ufficiale completo dei preset MeshCore. Il valore predefinito è EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Assicurati che il preset selezionato corrisponda alle normative radio locali.'; + + @override + String get wizardPresetNote2 => + 'L\'elenco corrisponde al feed ufficiale dei preset dello strumento di configurazione MeshCore.'; + + @override + String get wizardPresetNote3 => + 'EU/UK (Narrow) resta selezionato per impostazione predefinita durante l\'onboarding.'; + + @override + String get wizardSaving => 'Salvataggio...'; + + @override + String get wizardSaveAndContinue => 'Salva e continua'; + + @override + String get wizardEnterDeviceName => + 'Inserisci un nome dispositivo prima di continuare.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return '$deviceName salvato con $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Contatti, stanze e ripetitori'; + + @override + String get wizardNetworkDescription => + 'La scheda Contatti organizza la rete che scopri e i percorsi che apprendi nel tempo.'; + + @override + String get wizardNetworkFeature1 => + 'Controlla membri del team, ripetitori, stanze, canali e annunci in sospeso in un unico elenco.'; + + @override + String get wizardNetworkFeature2 => + 'Usa smart ping, accesso alle stanze, percorsi appresi e strumenti di reset delle route quando la connettività diventa complicata.'; + + @override + String get wizardNetworkFeature3 => + 'Crea canali e gestisci le destinazioni di rete senza uscire dall\'app.'; + + @override + String get wizardMapOpsTitle => 'Mappa, tracce e geometria condivisa'; + + @override + String get wizardMapOpsDescription => + 'La mappa dell\'app è collegata direttamente a messaggistica, tracciamento e overlay SAR invece di essere un visualizzatore separato.'; + + @override + String get wizardMapOpsFeature1 => + 'Monitora la tua posizione, le posizioni dei compagni e le tracce di movimento sulla mappa.'; + + @override + String get wizardMapOpsFeature2 => + 'Apri i disegni dai messaggi, visualizzali in anteprima in linea e rimuovili dalla mappa quando serve.'; + + @override + String get wizardMapOpsFeature3 => + 'Usa le viste mappa dei ripetitori e gli overlay condivisi per capire la copertura della rete sul campo.'; + + @override + String get wizardToolsTitle => 'Strumenti oltre la messaggistica'; + + @override + String get wizardToolsDescription => + 'Qui c\'è più delle quattro schede principali. L\'app include anche configurazione, diagnostica e flussi sensori opzionali.'; + + @override + String get wizardToolsFeature1 => + 'Apri la configurazione del dispositivo per cambiare impostazioni radio, telemetria, potenza TX e dettagli del companion.'; + + @override + String get wizardToolsFeature2 => + 'Abilita la scheda Sensori quando vuoi dashboard monitorate e azioni di aggiornamento rapido.'; + + @override + String get wizardToolsFeature3 => + 'Usa log dei pacchetti, scansione dello spettro e diagnostica sviluppatore per risolvere problemi della mesh.'; + + @override + String get postConnectDiscoveryTitle => 'Scoprire i ripetitori ora?'; + + @override + String get postConnectDiscoveryDescription => + 'Esegui la scoperta dei ripetitori subito dopo la connessione per vedere i nodi MeshCore nelle vicinanze e aggiungerli alla tua rete più velocemente.'; + + @override + String get contactInSensors => 'Nei Sensori'; + + @override + String get contactAddToSensors => 'Aggiungi ai Sensori'; + + @override + String get contactSetPath => 'Imposta percorso'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName aggiunto a Sensori'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Impossibile cancellare il percorso: $error'; + } + + @override + String get contactRouteCleared => 'Percorso cancellato'; + + @override + String contactRouteSet(String route) { + return 'Percorso impostato: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Impossibile impostare il percorso: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'Timeout ACK'; + + @override + String get opcode => 'Opcode'; + + @override + String get payload => 'Payload'; + + @override + String get hops => 'Hop'; + + @override + String get hashSize => 'Dimensione hash'; + + @override + String get pathBytes => 'Byte percorso'; + + @override + String get selectedPath => 'Percorso selezionato'; + + @override + String get estimatedTx => 'Trasmissione stimata'; + + @override + String get senderToReceipt => 'Mittente a ricevuta'; + + @override + String get receivedCopies => 'Copie ricevute'; + + @override + String get retryCause => 'Causa del ritentativo'; + + @override + String get retryMode => 'Modalità ritentativo'; + + @override + String get retryResult => 'Risultato ritentativo'; + + @override + String get lastRetry => 'Ultimo ritentativo'; + + @override + String get rxPackets => 'Pacchetti RX'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Velocità'; + + @override + String get window => 'Finestra'; + + @override + String get posttxDelay => 'Ritardo post-tx'; + + @override + String get bandpass => 'Passa-banda'; + + @override + String get bandpassFilterVoice => 'Filtro passa-banda voce'; + + @override + String get active => 'Attivo'; + + @override + String get addContact => 'Aggiungi contatto'; + + @override + String get all => 'Tutto'; + + @override + String get clearAllLabel => 'Cancella tutto'; + + @override + String get clearFilters => 'Cancella filtri'; + + @override + String get clearRoute => 'Cancella percorso'; + + @override + String get clearMessages => 'Cancella messaggi'; + + @override + String get clearScale => 'Cancella scala'; + + @override + String get clearDiscoveries => 'Cancella scoperte'; + + @override + String get clearOnlineTraceDatabase => 'Cancella database tracce'; + + @override + String get clearAllChannels => 'Cancella tutti i canali'; + + @override + String get clearAllContacts => 'Cancella tutti i contatti'; + + @override + String get clearChannels => 'Cancella canali'; + + @override + String get clearContacts => 'Cancella contatti'; + + @override + String get clearPathOnMaxRetry => 'Cancella percorso al max ritentativo'; + + @override + String get create => 'Crea'; + + @override + String get custom => 'Personalizzato'; + + @override + String get defaultValue => 'Predefinito'; + + @override + String get duplicate => 'Duplica'; + + @override + String get editName => 'Modifica nome'; + + @override + String get open => 'Apri'; + + @override + String get paste => 'Incolla'; + + @override + String get preview => 'Anteprima'; + + @override + String get remove => 'Rimuovi'; + + @override + String get rename => 'Rinomina'; + + @override + String get resolveAll => 'Risolvi tutto'; + + @override + String get send => 'Invia'; + + @override + String get sendAnyway => 'Invia comunque'; + + @override + String get share => 'Condividi'; + + @override + String get trace => 'Traccia'; + + @override + String get discovery => 'Scoperta'; + + @override + String get discoverRepeaters => 'Scopri ripetitori'; + + @override + String get discoverSensors => 'Scopri sensori'; + + @override + String get repeaterDiscoverySent => 'Scoperta ripetitori inviata'; + + @override + String get sensorDiscoverySent => 'Scoperta sensori inviata'; + + @override + String get clearedPendingDiscoveries => 'Scoperte in attesa cancellate.'; + + @override + String get autoDiscovery => 'Scoperta automatica'; + + @override + String get enableAutomaticAdding => 'Abilita aggiunta automatica'; + + @override + String get autoaddRepeaters => 'Aggiungi ripetitori automaticamente'; + + @override + String get autoaddRoomServers => 'Aggiungi server stanze automaticamente'; + + @override + String get autoaddSensors => 'Aggiungi sensori automaticamente'; + + @override + String get autoaddUsers => 'Aggiungi utenti automaticamente'; + + @override + String get overwriteOldestWhenFull => 'Sovrascrivi i più vecchi quando pieno'; + + @override + String get storage => 'Archiviazione'; + + @override + String get dangerZone => 'Zona pericolosa'; + + @override + String get profiles => 'Profili'; + + @override + String get favourites => 'Preferiti'; + + @override + String get sensors => 'Sensori'; + + @override + String get others => 'Altri'; + + @override + String get gpsModule => 'Modulo GPS'; + + @override + String get liveTraffic => 'Traffico in tempo reale'; + + @override + String get repeatersMap => 'Mappa ripetitori'; + + @override + String get blePacketLogs => 'Log pacchetti BLE'; + + @override + String get onlineTraceDatabase => 'Database tracce'; + + @override + String get routePathByteSize => 'Dimensione percorso in byte'; + + @override + String get messageNotifications => 'Notifiche messaggi'; + + @override + String get sarAlerts => 'Avvisi SAR'; + + @override + String get discoveryNotifications => 'Notifiche scoperte'; + + @override + String get updateNotifications => 'Notifiche aggiornamenti'; + + @override + String get muteWhileAppIsOpen => 'Silenzia con l\'app aperta'; + + @override + String get disableContacts => 'Disabilita contatti'; + + @override + String get enableSensorsTab => 'Abilita scheda Sensori'; + + @override + String get enableProfiles => 'Abilita profili'; + + @override + String get nearestRepeaterFallback => 'Ripetitore più vicino come fallback'; + + @override + String get deleteAllStoredMessageHistory => + 'Elimina tutta la cronologia messaggi'; + + @override + String get messageFontSize => 'Dimensione carattere messaggi'; + + @override + String get rotateMapWithHeading => 'Ruota mappa con direzione'; + + @override + String get showMapDebugInfo => 'Mostra info debug mappa'; + + @override + String get openMapInFullscreen => 'Apri mappa a schermo intero'; + + @override + String get showSarMarkersLabel => 'Mostra marcatori SAR'; + + @override + String get hideRepeatersOnMap => 'Nascondi ripetitori sulla mappa'; + + @override + String get setMapScale => 'Imposta scala mappa'; + + @override + String get customMapScaleSaved => 'Scala mappa personalizzata salvata'; + + @override + String get voiceBitrate => 'Bitrate voce'; + + @override + String get voiceCompressor => 'Compressore voce'; + + @override + String get voiceLimiter => 'Limitatore voce'; + + @override + String get micAutoGain => 'Guadagno automatico microfono'; + + @override + String get echoCancellation => 'Cancellazione eco'; + + @override + String get noiseSuppression => 'Soppressione rumore'; + + @override + String get trimSilenceInVoiceMessages => + 'Taglia silenzio nei messaggi vocali'; + + @override + String get compressor => 'Compressore'; + + @override + String get limiter => 'Limitatore'; + + @override + String get autoGain => 'Guadagno automatico'; + + @override + String get echoCancel => 'Eco'; + + @override + String get noiseSuppress => 'Rumore'; + + @override + String get silenceTrim => 'Silenzio'; + + @override + String get maxImageSize => 'Dimensione massima immagine'; + + @override + String get imageCompression => 'Compressione immagine'; + + @override + String get grayscale => 'Scala di grigi'; + + @override + String get ultraMode => 'Modalità ultra'; + + @override + String get fastPrivateGpsUpdates => 'Aggiornamenti GPS privati veloci'; + + @override + String get movementThreshold => 'Soglia di movimento'; + + @override + String get fastGpsMovementThreshold => 'Soglia movimento GPS veloce'; + + @override + String get fastGpsActiveuseInterval => 'Intervallo uso attivo GPS veloce'; + + @override + String get activeuseUpdateInterval => 'Intervallo aggiornamento uso attivo'; + + @override + String get repeatNearbyTraffic => 'Ripeti traffico vicino'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Inoltra tramite ripetitori nella rete'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Solo vicini, senza flood dei ripetitori'; + + @override + String get multihop => 'Multi-hop'; + + @override + String get createProfile => 'Crea profilo'; + + @override + String get renameProfile => 'Rinomina profilo'; + + @override + String get newProfile => 'Nuovo profilo'; + + @override + String get manageProfiles => 'Gestisci profili'; + + @override + String get enableProfilesToStartManagingThem => + 'Abilita i profili per iniziare a gestirli.'; + + @override + String get openMessage => 'Apri messaggio'; + + @override + String get jumpToTheRelatedSarMessage => 'Vai al messaggio SAR correlato'; + + @override + String get removeSarMarker => 'Rimuovi marcatore SAR'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Seleziona una destinazione per inviare il marcatore SAR'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'Marcatore SAR trasmesso al canale pubblico'; + + @override + String get sarMarkerSentToRoom => 'Marcatore SAR inviato alla stanza'; + + @override + String get loadFromGallery => 'Carica dalla galleria'; + + @override + String get replaceImage => 'Sostituisci immagine'; + + @override + String get selectFromGallery => 'Seleziona dalla galleria'; + + @override + String get team => 'Squadra'; + + @override + String get found => 'Trovato'; + + @override + String get staging => 'Area di raccolta'; + + @override + String get object => 'Oggetto'; + + @override + String get quiet => 'Silenzioso'; + + @override + String get busy => 'Occupato'; + + @override + String get searchMessages => 'Cerca messaggi'; + + @override + String get sendImageFromGallery => 'Invia immagine dalla galleria'; + + @override + String get takePhoto => 'Scatta foto'; + + @override + String get allMessages => 'Tutti i messaggi'; + + @override + String get sendToPublicChannel => 'Inviare al canale pubblico?'; + + @override + String get selectMarkerTypeAndDestination => + 'Seleziona tipo marcatore e destinazione'; + + @override + String get noDestinationsAvailableLabel => 'Nessuna destinazione disponibile'; + + @override + String get image => 'Immagine'; + + @override + String get format => 'Formato'; + + @override + String get dimensions => 'Dimensioni'; + + @override + String get segments => 'Segmenti'; + + @override + String get transfers => 'Trasferimenti'; + + @override + String get downloadedBy => 'Scaricato da'; + + @override + String get saveDiscoverySettings => 'Salva impostazioni scoperta'; + + @override + String get savePublicInfo => 'Salva info pubbliche'; + + @override + String get saveRadioSettings => 'Salva impostazioni radio'; + + @override + String get savePath => 'Salva percorso'; + + @override + String get wipeDeviceData => 'Cancella dati dispositivo'; + + @override + String get wipeDevice => 'Cancella dispositivo'; + + @override + String get destructiveDeviceActions => 'Azioni distruttive del dispositivo.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Scegli un preset o personalizza le impostazioni radio.'; + + @override + String get used => 'Usato'; + + @override + String get total => 'Totale'; + + @override + String get renameValue => 'Rinomina valore'; + + @override + String get customizeFields => 'Personalizza campi'; + + @override + String get livePreview => 'Anteprima dal vivo'; + + @override + String get refreshSchedule => 'Programma aggiornamento'; + + @override + String get noResponse => 'Nessuna risposta'; + + @override + String get refreshing => 'Aggiornamento'; + + @override + String get unavailable => 'Non disponibile'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Scegli un relay o nodo da monitorare.'; + + @override + String get publicKeyLabel => 'Chiave pubblica'; + + @override + String get alreadyInContacts => 'Già nei contatti'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Connettiti a un dispositivo prima di aggiungere contatti'; + + @override + String get fromContacts => 'Dai contatti'; + + @override + String get onlineOnly => 'Solo online'; + + @override + String get inBoth => 'In entrambi'; + + @override + String get source => 'Origine'; + + @override + String get allMessagesCleared => 'Tutti i messaggi cancellati'; + + @override + String get onlineTraceDatabaseCleared => 'Database tracce cancellata'; + + @override + String get packetLogsCleared => 'Log pacchetti cancellati'; + + @override + String get hexDataCopiedToClipboard => 'Dati hex copiati negli appunti'; + + @override + String get developerModeEnabled => 'Modalità sviluppatore attivata'; + + @override + String get developerModeDisabled => 'Modalità sviluppatore disattivata'; + + @override + String get clipboardIsEmpty => 'Gli appunti sono vuoti'; + + @override + String get contactImported => 'Contatto importato'; + + @override + String get contactLinkCopiedToClipboard => + 'Link contatto copiato negli appunti'; + + @override + String get failedToExportContact => 'Esportazione contatto fallita'; + + @override + String get noLogsToExport => 'Nessun log da esportare'; + + @override + String get exportAsCsv => 'Esporta come CSV'; + + @override + String get exportAsText => 'Esporta come testo'; + + @override + String get receivedRfc3339 => 'Ricevuto (RFC3339)'; + + @override + String get buildTime => 'Ora build'; + + @override + String get downloadUrlNotAvailable => 'URL di download non disponibile'; + + @override + String get cannotOpenDownloadUrl => 'Impossibile aprire URL di download'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'Il controllo aggiornamenti è disponibile solo su Android'; + + @override + String get youAreRunningTheLatestVersion => + 'Stai utilizzando l\'ultima versione'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Aggiornamento disponibile ma URL di download non trovato'; + + @override + String get startTictactoe => 'Avvia Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe non disponibile'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: avversario sconosciuto'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: in attesa dell\'inizio'; + + @override + String get acceptsShareLinks => 'Accetta link condivisi'; + + @override + String get supportsRawHex => 'Supporta hex grezzo'; + + @override + String get clipboardfriendly => 'Compatibile con appunti'; + + @override + String get captured => 'Catturato'; + + @override + String get size => 'Dimensione'; + + @override + String get noCustomChannelsToClear => + 'Nessun canale personalizzato da cancellare.'; + + @override + String get noDeviceContactsToClear => + 'Nessun contatto dispositivo da cancellare.'; + + @override + String get aToZ => 'A-Z'; + + @override + String get add => 'Aggiungi'; + + @override + String get addAdditionalDetails => 'Aggiungi dettagli aggiuntivi'; + + @override + String get addPeer => 'Aggiungi peer'; + + @override + String get addPeerManually => 'Aggiungi peer manualmente'; + + @override + String addedToContacts(String name) { + return '$name aggiunto ai contatti'; + } + + @override + String addedToSensors(String name) { + return '$name aggiunto ai sensori'; + } + + @override + String get allowAll => 'Consenti tutto'; + + @override + String get anonymousRxStats => 'Statistiche RX anonime'; + + @override + String get appearance => 'Aspetto'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Telemetria di base'; + + @override + String get blePin => 'PIN BLE'; + + @override + String get buzzerAlerts => 'Avvisi buzzer'; + + @override + String get cancelImageReceive => 'Annulla ricezione immagine'; + + @override + String get choose => 'Scegli'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Scegli quali schede e sezioni contatti mostrare'; + + @override + String get clearCache => 'Svuota cache'; + + @override + String get clearHistory => 'Cancella cronologia'; + + @override + String get clearLiveView => 'Cancella vista live'; + + @override + String get clearLogs => 'Cancella log'; + + @override + String get clearOfflineCache => 'Svuotare la cache offline?'; + + @override + String get clearOverlay => 'Cancella sovrapposizione'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Cancella il percorso solo dopo che tutti i tentativi falliscono'; + + @override + String get codecBitrateAndAudioProcessing => + 'Codec, bitrate ed elaborazione audio'; + + @override + String get connectByIpAddress => 'Connetti tramite indirizzo IP'; + + @override + String get controlWhichAlertsYouReceive => 'Controlla quali avvisi ricevi'; + + @override + String get copyFullHex => 'Copia Hex completo'; + + @override + String get copyRawResponse => 'Copia risposta grezza'; + + @override + String get couldNotLoadMetHistory => 'Impossibile caricare la cronologia MET'; + + @override + String customizeSensor(String name) { + return 'Personalizza $name'; + } + + @override + String get data => 'Dati'; + + @override + String get deleteGroup => 'Elimina gruppo'; + + @override + String deleteStyleConfirm(String name) { + return 'Eliminare $name?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Eliminare questo $type?'; + } + + @override + String get deny => 'Nega'; + + @override + String get deviceInfo => 'Info dispositivo'; + + @override + String get deviceSettings => 'Impostazioni dispositivo'; + + @override + String get deviceTimeSynced => + 'Ora del dispositivo sincronizzata con questo telefono.'; + + @override + String get disableFastGpsPublishing => 'Disabilita pubblicazione GPS rapida'; + + @override + String get displayMarkersAndTraceDatabase => + 'Visualizzazione, marcatori e database tracce'; + + @override + String get download => 'Scarica'; + + @override + String get environmentalTelemetry => 'Telemetria ambientale'; + + @override + String errorCheckingForUpdates(String error) { + return 'Errore durante il controllo degli aggiornamenti: $error'; + } + + @override + String errorGeneric(String error) { + return 'Errore: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Errore nell\'apertura del download: $error'; + } + + @override + String get exportLogs => 'Esporta log'; + + @override + String get extraAggressiveAvifCompression => + 'Compressione AVIF extra aggressiva'; + + @override + String failedToAddContact(String error) { + return 'Impossibile aggiungere il contatto: $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Impossibile aggiungere $name: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Impossibile cancellare i canali: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Impossibile cancellare i contatti: $error'; + } + + @override + String get failedToConnectViaSerial => + 'Impossibile connettersi tramite seriale'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Impossibile caricare l\'anteprima: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Impossibile caricare la traccia: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Impossibile inviare il marcatore SAR: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Impossibile impostare la modalità buzzer: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Impossibile impostare la modalità GPS: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Impossibile sincronizzare l\'ora del dispositivo: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Impossibile cancellare i dati del dispositivo: $error'; + } + + @override + String get fastGpsTargetChannel => 'Canale target GPS rapido'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Aggiornamenti rapidi, soglie e permessi'; + + @override + String get fetchingCatalog => 'Recupero catalogo...'; + + @override + String get fifteenMinutes => '15 minuti'; + + @override + String get fifteenSeconds => '15 secondi'; + + @override + String get filterByDirection => 'Filtra per direzione'; + + @override + String get finish => 'Fine'; + + @override + String get fiveMinutes => '5 minuti'; + + @override + String get fiveSeconds => '5 secondi'; + + @override + String get fix => 'Fix'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'Politica annunci GPS'; + + @override + String get gpsAndLocation => 'GPS e posizione'; + + @override + String get gpsDiagnostics => 'Diagnostica GPS'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Aiuto'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Nascosto'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Come la radio aggiunge automaticamente i nodi scoperti.'; + + @override + String get images => 'Immagini'; + + @override + String get importProfile => 'Importa profilo'; + + @override + String get lastFix => 'Ultimo fix'; + + @override + String get latest => 'Più recente'; + + @override + String get latitude => 'Latitudine'; + + @override + String get loadASavedRegion => 'Carica una regione salvata'; + + @override + String get loading => 'Caricamento'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Condivisione posizione, hardware e intervallo di aggiornamento.'; + + @override + String get locationTelemetry => 'Telemetria posizione'; + + @override + String get lockDestination => 'Blocca destinazione'; + + @override + String get longitude => 'Longitudine'; + + @override + String get mapStyle => 'Stile mappa'; + + @override + String get max => 'Max'; + + @override + String get maxZoom => 'Zoom max'; + + @override + String get messaging => 'Messaggistica'; + + @override + String get metHistory => 'Cronologia MET'; + + @override + String get min => 'Min'; + + @override + String get minZoom => 'Zoom min'; + + @override + String get moveDown => 'Sposta giù'; + + @override + String get moveUp => 'Sposta su'; + + @override + String get multiAckMode => 'Modalità multi-ACK'; + + @override + String get multiDeviceWorkspaceManagement => + 'Gestione spazio di lavoro multi-dispositivo'; + + @override + String get myLocation => 'La mia posizione'; + + @override + String get nameAndTelemetryShared => + 'Nome e telemetria condivisi con altri dispositivi.'; + + @override + String newVersionAvailable(String version) { + return 'Nuova versione: $version'; + } + + @override + String get newer => 'Più recente'; + + @override + String get noNeighboursFound => 'Nessun vicino trovato'; + + @override + String get noServersFound => 'Nessun server trovato'; + + @override + String get notSet => 'Non impostato'; + + @override + String get notifications => 'Notifiche'; + + @override + String get offZeroSeconds => 'Off (0 s)'; + + @override + String get offlineMaps => 'Mappe offline'; + + @override + String get older => 'Meno recente'; + + @override + String get oneByteMode0 => '1 byte (modo 0)'; + + @override + String get oneHour => '1 ora'; + + @override + String get oneMinute => '1 minuto'; + + @override + String get openPacketLogs => 'Apri log pacchetti'; + + @override + String get packetTypeHelp => 'Guida tipi di pacchetto'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Incolla un link di condivisione o annuncio Hex'; + + @override + String get pathHash => 'Hash percorso'; + + @override + String get pathSize => 'Dimensione percorso'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Poligono'; + + @override + String get rawResponseCopied => 'Risposta grezza copiata'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Rinvia tramite il ripetitore più vicino in caso di errore'; + + @override + String get resolveContact => 'Risolvi contatto'; + + @override + String get routingRetriesAndDestinationLock => + 'Instradamento, tentativi e blocco destinazione'; + + @override + String get samples => 'Campioni'; + + @override + String get satellites => 'Satelliti'; + + @override + String get saving => 'Salvataggio...'; + + @override + String get search => 'Cerca'; + + @override + String get searchByNameOrToken => 'Cerca per nome o token'; + + @override + String get searchDiscoveredNodes => 'Cerca nodi scoperti'; + + @override + String get searchInCurrentFilter => 'Cerca nel filtro corrente'; + + @override + String get searchLogs => 'Cerca nei log...'; + + @override + String get searchSensors => 'Cerca sensori'; + + @override + String get searchThisSection => 'Cerca in questa sezione'; + + @override + String get selectedRelays => 'Ripetitori selezionati'; + + @override + String get sensor => 'Sensore'; + + @override + String serialError(String error) { + return 'Errore seriale: $error'; + } + + @override + String setPathForContact(String name) { + return 'Imposta percorso per $name'; + } + + @override + String get shareLiveGps => 'Condividi GPS in tempo reale'; + + @override + String get shareMyTiles => 'Condividi le mie tile'; + + @override + String get sharingLocationFromPhone => + 'Condivisione posizione su questo canale dal telefono.'; + + @override + String get sharingLocationFromRadio => + 'Condivisione posizione su questo canale dalla radio.'; + + @override + String signalDbm(String rssi) { + return 'Segnale $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => + 'Dimensione, compressione e anteprima'; + + @override + String get simpleMode => 'Modalità semplice'; + + @override + String get simpleModeDescription => + 'Solo chat e mappa con i contatti preferiti'; + + @override + String get simpleModeRequiresLockedDestination => + 'Attiva prima «Blocca destinazione» con un canale per usare la modalità semplice'; + + @override + String get noFavouriteContacts => 'Nessun contatto preferito'; + + @override + String get skip => 'Salta'; + + @override + String get smallerFileSize => 'Dimensione file ridotta'; + + @override + String get sort => 'Ordina'; + + @override + String get stoppedSharingLocation => + 'Condivisione posizione interrotta su questo canale.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Disattiva notifiche quando l\'app è in primo piano'; + + @override + String get tabsAndNavigation => 'Schede e navigazione'; + + @override + String get templatesAndTutorials => 'Modelli e tutorial'; + + @override + String get tenMinutes => '10 minuti'; + + @override + String get tenSeconds => '10 secondi'; + + @override + String get testSendUpdate => 'Prova invio aggiornamento'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Tema, lingua e preferenze di visualizzazione'; + + @override + String get thirtyMinutes => '30 minuti'; + + @override + String get thirtySeconds => '30 secondi'; + + @override + String get threeBytesMode2 => '3 byte (modo 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Statistiche traffico, cronologia messaggi e strumenti sviluppatore'; + + @override + String get twoBytesMode1 => '2 byte (modo 1)'; + + @override + String get twoMinutes => '2 minuti'; + + @override + String get undo => 'Annulla'; + + @override + String get updated => 'Aggiornato'; + + @override + String get useContactFlags => 'Usa flag contatti'; + + @override + String get useSavedCoordinates => 'Usa coordinate salvate'; + + @override + String get view => 'Visualizza'; + + @override + String get viewNeighbours => 'Visualizza vicini'; + + @override + String get viewPublicStats => 'Visualizza statistiche pubbliche'; + + @override + String get setRegionScope => 'Imposta ambito regionale'; + + @override + String get regionScope => 'Ambito regionale'; + + @override + String get regionScopeNone => 'Nessuno (globale)'; + + @override + String get clearRegionScope => 'Cancella ambito'; + + @override + String get regionScopeWarning => + 'Solo i ripetitori che consentono questa regione inoltreranno.'; + + @override + String get discoverRegions => 'Scopri dai ripetitori'; + + @override + String get discoveringRegions => 'Scoperta delle regioni...'; + + @override + String get enterRegionName => 'Nome regione (es. auckland)'; + + @override + String get noRegionsFound => 'Nessuna regione trovata su questo ripetitore.'; + + @override + String regionScopeSet(String name) { + return 'Ambito regionale impostato su $name'; + } + + @override + String get regionScopeCleared => 'Ambito regionale cancellato'; + + @override + String get selectDestinationFirst => + 'Seleziona prima un canale, un contatto o una stanza'; + + @override + String get recipientNoLongerAvailable => + 'Il destinatario non è più disponibile'; + + @override + String failedToSendMessage(String error) { + return 'Invio non riuscito: $error'; + } + + @override + String get tictactoeDirectOnly => + 'Il tris funziona solo nei messaggi diretti. Scegli prima un contatto.'; + + @override + String get deviceKeyUnavailable => + 'La chiave del dispositivo non è disponibile'; + + @override + String get imageCompressionFailed => + 'Compressione dell\'immagine non riuscita'; + + @override + String get imageFragmentationFailed => + 'Frammentazione dell\'immagine non riuscita'; + + @override + String get failedToAnnounceImage => 'Annuncio dell\'immagine non riuscito'; + + @override + String get noRecipientSelected => 'Nessun destinatario selezionato'; + + @override + String get imageSendFailed => 'Invio dell\'immagine non riuscito'; + + @override + String get microphonePermissionRequiredForVoice => + 'Per i messaggi vocali è necessario il permesso del microfono'; + + @override + String get recordingDiscarded => 'Registrazione scartata'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Stai per inviare $mediaType al Canale Pubblico. Non è consigliato, perché chiunque sulla rete mesh potrebbe riceverlo. Scegli un canale privato o con tag, a meno che non sia ciò che vuoi.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Seleziona un canale privato per condividere la tua posizione.'; + + @override + String get stopSharingMyLocation => 'Smetti di condividere la mia posizione'; + + @override + String get shareMyLocation => 'Condividi la mia posizione'; + + @override + String get stopRecording => 'Interrompi registrazione'; + + @override + String get recordVoice => 'Registra messaggio vocale'; + + @override + String get moreActions => 'Altre azioni'; + + @override + String get noMessagesInFilter => 'Nessun messaggio in questo filtro'; + + @override + String get noMatchesInFilter => 'Nessuna corrispondenza in questo filtro'; + + @override + String syncFailed(String error) { + return 'Sincronizzazione non riuscita: $error'; + } + + @override + String get invalidLink => 'Link non valido'; + + @override + String get cannotOpenLink => 'Impossibile aprire il link'; + + @override + String get failedToOpenLink => 'Apertura del link non riuscita'; + + @override + String get failedToResendMessage => 'Reinvio del messaggio non riuscito'; + + @override + String retryFailed(String error) { + return 'Nuovo tentativo non riuscito: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Elimina disegno e messaggio'; + + @override + String get lastEchoRelay => 'Ultimo eco: relay'; + + @override + String get lastEchoPath => 'Ultimo eco: percorso'; + + @override + String get lastEchoBytesReport => 'Ultimo eco: report byte'; + + @override + String get noGpsCoordinatesAvailable => 'Nessuna coordinata GPS disponibile'; + + @override + String get notASarMarker => 'Non è un marcatore SAR'; + + @override + String failedToSaveTemplate(String error) { + return 'Salvataggio del modello non riuscito: $error'; + } + + @override + String get drawingNotFound => 'Disegno non trovato'; + + @override + String get unknownDrawingType => 'Tipo di disegno sconosciuto'; + + @override + String get customMapMarker => 'Marcatore mappa personalizzato'; + + @override + String customMapPointLabel(String point) { + return 'Punto: $point'; + } + + @override + String mapIdLabel(String id) { + return 'ID mappa: $id'; + } + + @override + String get cannotFetchVoice => 'Impossibile recuperare il vocale'; + + @override + String get senderContactUnknown => + 'Il contatto del mittente è sconosciuto. Sincronizza prima i contatti.'; + + @override + String get senderRouteUnknown => + 'Il percorso del mittente è sconosciuto. Sincronizza prima contatti/percorso.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Il messaggio è troppo lontano ($hops hop, max $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'Il percorso del mittente non ha risposto alla verifica. Sincronizza contatti/percorso e riprova.'; + + @override + String get senderRouteNoRawResponse => + 'Il percorso del mittente non ha risposto sul percorso di trasporto raw.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Il recupero del vocale su $hops hop può richiedere un po\' di tempo.'; + } + + @override + String get voiceReceiveCanceled => 'Ricezione vocale annullata'; + + @override + String get fetchingMissingVoiceFragments => + 'Recupero dei frammenti vocali mancanti'; + + @override + String get receivingVoice => 'Ricezione vocale'; + + @override + String get cannotFetchImage => 'Impossibile recuperare l\'immagine'; + + @override + String imageFetchOverHops(String hops) { + return 'Il recupero dell\'immagine su $hops hop può richiedere un po\' di tempo.'; + } + + @override + String get imageAlreadyBeingReceived => 'L\'immagine è già in ricezione'; + + @override + String get loadImage => 'Carica immagine'; + + @override + String get imageFetchFailedToSendRequest => + 'Invio della richiesta immagine non riuscito'; + + @override + String get imageUnavailable => 'Immagine al momento non disponibile'; + + @override + String get imageFetchTimedOut => 'Recupero dell\'immagine scaduto'; + + @override + String get imageReceiveCanceled => 'Ricezione dell\'immagine annullata'; + + @override + String get closeImagePreview => 'Chiudi anteprima immagine'; + + @override + String get recordingReleaseToSend => 'Registrazione... rilascia per inviare'; + + @override + String get sendingVoice => 'Invio vocale...'; + + @override + String get sendLongPressToRecordVoice => + 'Invia (pressione lunga per registrare un vocale)'; + + @override + String get discardRecording => 'Scarta registrazione'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hop', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Canali broadcast per il traffico mesh nelle vicinanze'; + + @override + String get roomsSectionDescription => + 'Spazi condivisi per il coordinamento continuo del team'; + + @override + String get contactsSectionDescription => + 'Persone e dispositivi raggiungibili direttamente'; + + @override + String get noRecentChatters => 'Nessun partecipante recente'; + + @override + String get public => 'Pubblico'; + + @override + String get failedToSendTicTacToeMove => + 'Invio della mossa di tris non riuscito'; + + @override + String get youWon => 'Hai vinto'; + + @override + String get opponentWon => 'Ha vinto l\'avversario'; + + @override + String get gameDraw => 'Pareggio'; + + @override + String get yourTurn => 'Tocca a te'; + + @override + String get opponentTurn => 'Tocca all\'avversario'; + + @override + String get pleaseEnterValidCoordinates => 'Inserisci coordinate valide'; + + @override + String get locationNotAvailable => 'Posizione non disponibile'; + + @override + String get customCaveMapPoint => 'Punto mappa grotta personalizzato'; + + @override + String get mapPoint => 'Punto mappa'; + + @override + String get fetchingMissingImageFragments => 'Recupero frammenti mancanti…'; + + @override + String get loadingImage => 'Caricamento…'; + + @override + String get receivingImage => 'Ricezione…'; + + @override + String get tapToLoad => 'Tocca per caricare'; + + @override + String get connectDevice => 'Connetti dispositivo'; + + @override + String get chooseTransportSubtitle => + 'Scegli il trasporto Bluetooth, WiFi o Seriale'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Rete'; + + @override + String get serial => 'Seriale'; + + @override + String get scan => 'Cerca'; + + @override + String get pressScanToSearchForDevices => + 'Premi Cerca per trovare i dispositivi nelle vicinanze'; + + @override + String failedToConnectToDevice(String name) { + return 'Impossibile connettersi a $name'; + } + + @override + String get unknownDevice => 'Dispositivo sconosciuto'; + + @override + String get showingCachedResultsTapRefresh => + 'Risultati in cache. Tocca Aggiorna per ripetere la scansione.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Scansione della rete locale per dispositivi MeshCore WiFi sulla porta $port'; + } + + @override + String get cancelScanAndAddServer => + 'Annulla la scansione e aggiungi un server'; + + @override + String get addServer => 'Aggiungi server'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Scansione... $scanned/$total IP'; + } + + @override + String get cancelAndEnterManually => 'Annulla e inserisci manualmente'; + + @override + String get noRecentOrDiscoveredServers => + 'Nessun server recente o rilevato per ora'; + + @override + String get recentlyUsed => 'Usati di recente'; + + @override + String get connecting => 'Connessione...'; + + @override + String get discoveredOnThisNetwork => 'Rilevati su questa rete'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'Il server $host:$port non è più disponibile. Esegui di nuovo la scansione per trovare server attivi.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Impossibile connettersi a $host:$port'; + } + + @override + String get enterValidIpAddress => 'Inserisci un indirizzo IP valido'; + + @override + String get enterValidTcpPort => 'Inserisci una porta TCP valida'; + + @override + String get ipAddress => 'Indirizzo IP'; + + @override + String get tcpPort => 'Porta TCP'; + + @override + String get customServerPort => 'Porta server personalizzata'; + + @override + String get chat => 'Chat'; + + @override + String get restoringPreviousLink => 'Ripristino del collegamento precedente'; + + @override + String get noDeviceConnected => 'Nessun dispositivo connesso'; + + @override + String get sendAdvert => 'Invia il mio contatto'; + + @override + String get advertMode => 'Invia il mio contatto'; + + @override + String get advertModeSubtitle => + 'Scegli quanto lontano devono viaggiare le tue informazioni di contatto.'; + + @override + String get floodAdvertSent => 'Contatto inviato attraverso la rete mesh'; + + @override + String get directAdvertSent => 'Contatto inviato ai dispositivi vicini'; +} diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart new file mode 100644 index 0000000..2e7c403 --- /dev/null +++ b/lib/l10n/app_localizations_pl.dart @@ -0,0 +1,3450 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Polish (`pl`). +class AppLocalizationsPl extends AppLocalizations { + AppLocalizationsPl([String locale = 'pl']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Wiadomości'; + + @override + String get contacts => 'Kontakty'; + + @override + String get map => 'Mapa'; + + @override + String get settings => 'Ustawienia'; + + @override + String get connect => 'Połącz'; + + @override + String get disconnect => 'Rozłącz'; + + @override + String get noDevicesFound => 'Nie znaleziono urządzeń'; + + @override + String get scanAgain => 'Skanuj ponownie'; + + @override + String get deviceNotConnected => 'Urządzenie nie jest połączone'; + + @override + String get locationPermissionDenied => 'Odmówiono dostępu do lokalizacji'; + + @override + String get locationPermissionPermanentlyDenied => + 'Dostęp do lokalizacji został trwale zablokowany. Włącz go w Ustawieniach.'; + + @override + String get locationPermissionRequired => + 'Dostęp do lokalizacji jest wymagany do śledzenia GPS i koordynacji zespołu. Możesz go włączyć później w Ustawieniach.'; + + @override + String get locationServicesDisabled => + 'Usługi lokalizacji są wyłączone. Włącz je w Ustawieniach.'; + + @override + String get failedToGetGpsLocation => 'Nie udało się pobrać lokalizacji GPS'; + + @override + String failedToAdvertise(String error) { + return 'Nie udało się rozgłosić: $error'; + } + + @override + String get cancelReconnection => 'Anuluj ponowne łączenie'; + + @override + String get general => 'Ogólne'; + + @override + String get theme => 'Motyw'; + + @override + String get chooseTheme => 'Wybierz motyw'; + + @override + String get light => 'Jasny'; + + @override + String get dark => 'Ciemny'; + + @override + String get blueLightTheme => 'Niebieski jasny motyw'; + + @override + String get blueDarkTheme => 'Niebieski ciemny motyw'; + + @override + String get sarRed => 'SAR Czerwony'; + + @override + String get alertEmergencyMode => 'Tryb alarmowy/awaryjny'; + + @override + String get sarGreen => 'SAR Zielony'; + + @override + String get safeAllClearMode => 'Tryb bezpieczny/wszystko jasne'; + + @override + String get autoSystem => 'Automatyczny (system)'; + + @override + String get followSystemTheme => 'Dopasuj do motywu systemowego'; + + @override + String get showRxTxIndicators => 'Pokaż wskaźniki RX/TX'; + + @override + String get disableMap => 'Wyłącz mapę'; + + @override + String get language => 'Język'; + + @override + String get chooseLanguage => 'Wybierz język'; + + @override + String get save => 'Zapisz'; + + @override + String get cancel => 'Anuluj'; + + @override + String get close => 'Zamknij'; + + @override + String get about => 'Informacje'; + + @override + String get appVersion => 'Wersja aplikacji'; + + @override + String get aboutMeshCoreSar => 'O MeshCore SAR'; + + @override + String get aboutDescription => + 'Aplikacja poszukiwawczo-ratownicza zaprojektowana dla zespołów reagowania kryzysowego. Funkcje obejmują:\n\n• Sieć BLE mesh do komunikacji urządzenie-urządzenie\n• Mapy offline z wieloma warstwami\n• Śledzenie członków zespołu w czasie rzeczywistym\n• Taktyczne znaczniki SAR (odnaleziona osoba, pożar, strefa zbiórki)\n• Zarządzanie kontaktami i wiadomościami\n• Śledzenie GPS z kompasem\n• Buforowanie kafelków mapy do użytku offline'; + + @override + String get technologiesUsed => 'Użyte technologie:'; + + @override + String get technologiesList => + '• Flutter do rozwoju wieloplatformowego\n• BLE (Bluetooth Low Energy) do sieci mesh\n• OpenStreetMap do map\n• Provider do zarządzania stanem\n• SharedPreferences do pamięci lokalnej'; + + @override + String get moreInfo => 'Więcej informacji'; + + @override + String get packageName => 'Nazwa pakietu'; + + @override + String get sampleData => 'Dane przykładowe'; + + @override + String get sampleDataDescription => + 'Wczytaj lub wyczyść przykładowe kontakty, wiadomości kanałów i znaczniki SAR do testów'; + + @override + String get loadSampleData => 'Wczytaj dane przykładowe'; + + @override + String get clearAllData => 'Wyczyść wszystkie dane'; + + @override + String get clearAllDataConfirmTitle => 'Wyczyść wszystkie dane'; + + @override + String get clearAllDataConfirmMessage => + 'To wyczyści wszystkie kontakty i znaczniki SAR. Czy na pewno?'; + + @override + String get clear => 'Wyczyść'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Wczytano $teamCount członków zespołu, $channelCount kanałów, $sarCount znaczników SAR, $messageCount wiadomości'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Nie udało się wczytać danych przykładowych: $error'; + } + + @override + String get allDataCleared => 'Wszystkie dane wyczyszczono'; + + @override + String get failedToStartBackgroundTracking => + 'Nie udało się uruchomić śledzenia w tle. Sprawdź uprawnienia i połączenie BLE.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Nadawanie lokalizacji: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'Domyślny PIN dla urządzeń bez ekranu to 123456. Problem z parowaniem? Zapomnij urządzenie Bluetooth w ustawieniach systemu.'; + + @override + String get noMessagesYet => 'Brak wiadomości'; + + @override + String get pullDownToSync => + 'Przeciągnij w dół, aby zsynchronizować wiadomości'; + + @override + String get deleteContact => 'Usuń kontakt'; + + @override + String get delete => 'Usuń'; + + @override + String get viewOnMap => 'Pokaż na mapie'; + + @override + String get refresh => 'Odśwież'; + + @override + String get resetPath => 'Resetuj trasę (wyznacz ponownie)'; + + @override + String copiedToClipboard(String label) { + return 'Skopiowano $label do schowka'; + } + + @override + String get pleaseEnterPassword => 'Wprowadź hasło'; + + @override + String failedToSyncContacts(String error) { + return 'Nie udało się zsynchronizować kontaktów: $error'; + } + + @override + String get loggedInSuccessfully => + 'Zalogowano pomyślnie! Oczekiwanie na wiadomości z pokoju...'; + + @override + String get loginFailed => 'Logowanie nie powiodło się - nieprawidłowe hasło'; + + @override + String loggingIn(String roomName) { + return 'Logowanie do $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Nie udało się wysłać logowania: $error'; + } + + @override + String get lowLocationAccuracy => 'Niska dokładność lokalizacji'; + + @override + String get continue_ => 'Kontynuuj'; + + @override + String get sendSarMarker => 'Wyślij znacznik SAR'; + + @override + String get deleteDrawing => 'Usuń rysunek'; + + @override + String get drawingTools => 'Narzędzia rysowania'; + + @override + String get drawLine => 'Rysuj linię'; + + @override + String get drawLineDesc => 'Narysuj odręczną linię na mapie'; + + @override + String get drawRectangle => 'Rysuj prostokąt'; + + @override + String get drawRectangleDesc => 'Narysuj prostokątny obszar na mapie'; + + @override + String get measureDistance => 'Mierz odległość'; + + @override + String get measureDistanceDesc => 'Przytrzymaj dwa punkty, aby zmierzyć'; + + @override + String get clearMeasurement => 'Wyczyść pomiar'; + + @override + String distanceLabel(String distance) { + return 'Odległość: $distance'; + } + + @override + String get longPressForSecondPoint => 'Przytrzymaj dla drugiego punktu'; + + @override + String get longPressToStartMeasurement => + 'Przytrzymaj, aby ustawić pierwszy punkt'; + + @override + String get longPressToStartNewMeasurement => + 'Przytrzymaj, aby rozpocząć nowy pomiar'; + + @override + String get shareDrawings => 'Udostępnij rysunki'; + + @override + String get clearAllDrawings => 'Wyczyść wszystkie rysunki'; + + @override + String get completeLine => 'Zakończ linię'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Nadaj $count rysunek$plural do zespołu'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Usuń wszystkie $count rysunek$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Usunąć wszystkie $count rysunek$plural z mapy?'; + } + + @override + String get drawing => 'Rysunek'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Udostępnij $count rysunek$plural'; + } + + @override + String get showReceivedDrawings => 'Pokaż odebrane rysunki'; + + @override + String get showingAllDrawings => 'Pokazywane są wszystkie rysunki'; + + @override + String get showingOnlyYourDrawings => 'Pokazywane są tylko twoje rysunki'; + + @override + String get showSarMarkers => 'Pokaż znaczniki SAR'; + + @override + String get showingSarMarkers => 'Pokazywane są znaczniki SAR'; + + @override + String get hidingSarMarkers => 'Ukrywanie znaczników SAR'; + + @override + String get clearAll => 'Wyczyść wszystko'; + + @override + String get publicChannel => 'Kanał publiczny'; + + @override + String get broadcastToAll => + 'Nadaj do wszystkich pobliskich węzłów (tymczasowo)'; + + @override + String get storedPermanently => 'Przechowywane na stałe w pokoju'; + + @override + String get notConnectedToDevice => 'Brak połączenia z urządzeniem'; + + @override + String get typeYourMessage => 'Wpisz wiadomość...'; + + @override + String get quickLocationMarker => 'Szybki znacznik lokalizacji'; + + @override + String get markerType => 'Typ znacznika'; + + @override + String get sendTo => 'Wyślij do'; + + @override + String get noDestinationsAvailable => 'Brak dostępnych odbiorców.'; + + @override + String get selectDestination => 'Wybierz odbiorcę...'; + + @override + String get ephemeralBroadcastInfo => + 'Tymczasowe: nadawane tylko drogą radiową. Nie jest zapisywane - węzły muszą być online.'; + + @override + String get persistentRoomInfo => + 'Trwałe: zapisywane niezmiennie w pokoju. Synchronizowane automatycznie i zachowywane offline.'; + + @override + String get location => 'Lokalizacja'; + + @override + String get fromMap => 'Z mapy'; + + @override + String get gettingLocation => 'Pobieranie lokalizacji...'; + + @override + String get locationError => 'Błąd lokalizacji'; + + @override + String get retry => 'Ponów'; + + @override + String get refreshLocation => 'Odśwież lokalizację'; + + @override + String accuracyMeters(int accuracy) { + return 'Dokładność: ±${accuracy}m'; + } + + @override + String get notesOptional => 'Notatki (opcjonalnie)'; + + @override + String get addAdditionalInformation => 'Dodaj dodatkowe informacje...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'Dokładność lokalizacji wynosi ±${accuracy}m. To może nie być wystarczająco dokładne dla działań SAR.\n\nKontynuować mimo to?'; + } + + @override + String get loginToRoom => 'Zaloguj do pokoju'; + + @override + String get enterPasswordInfo => + 'Wprowadź hasło, aby uzyskać dostęp do tego pokoju. Hasło zostanie zapisane na przyszłość.'; + + @override + String get password => 'Hasło'; + + @override + String get enterRoomPassword => 'Wpisz hasło pokoju'; + + @override + String get loggingInDots => 'Logowanie...'; + + @override + String get login => 'Zaloguj'; + + @override + String failedToAddRoom(String error) { + return 'Nie udało się dodać pokoju do urządzenia: $error\n\nPokój mógł jeszcze nie rozpocząć nadawania.\nSpróbuj poczekać, aż pokój zacznie nadawać.'; + } + + @override + String get direct => 'Bezpośrednie'; + + @override + String get flood => 'Rozgłoszeniowo'; + + @override + String get autoSend => 'Automatyczne wysyłanie'; + + @override + String get autoSendDescription => 'Wyślij bieżącą trasą.'; + + @override + String get sendDirect => 'Wysyłanie bezpośrednie'; + + @override + String get sendDirectDescription => 'Wyślij bezpośrednio do tego kontaktu.'; + + @override + String get sendFlood => 'Wysyłanie zalewowe'; + + @override + String get sendFloodDescription => 'Wyślij przez wszystkie przekaźniki.'; + + @override + String get loggedIn => 'Zalogowano'; + + @override + String get noGpsData => 'Brak danych GPS'; + + @override + String get distance => 'Odległość'; + + @override + String directPingTimeout(String name) { + return 'Przekroczono czas bezpośredniego ping - ponawianie dla $name przez flooding...'; + } + + @override + String pingFailed(String name) { + return 'Ping do $name nie powiódł się - nie otrzymano odpowiedzi'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Czy na pewno chcesz usunąć „$name”?\n\nTo usunie kontakt zarówno z aplikacji, jak i z towarzyszącego urządzenia radiowego.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Nie udało się usunąć kontaktu: $error'; + } + + @override + String get type => 'Typ'; + + @override + String get publicKey => 'Klucz publiczny'; + + @override + String get lastSeen => 'Ostatnio widziany'; + + @override + String get yes => 'Tak'; + + @override + String get no => 'Nie'; + + @override + String get permissions => 'Uprawnienia'; + + @override + String get telemetry => 'Telemetria'; + + @override + String get voltage => 'Napięcie'; + + @override + String get battery => 'Bateria'; + + @override + String get temperature => 'Temperatura'; + + @override + String get humidity => 'Wilgotność'; + + @override + String get pressure => 'Ciśnienie'; + + @override + String get gpsTelemetry => 'GPS (telemetria)'; + + @override + String get reLoginToRoom => 'Zaloguj ponownie do pokoju'; + + @override + String get heading => 'Kierunek'; + + @override + String get elevation => 'Wysokość'; + + @override + String get accuracy => 'Dokładność'; + + @override + String get bearing => 'Namiar'; + + @override + String get direction => 'Kierunek'; + + @override + String get filterMarkers => 'Filtruj znaczniki'; + + @override + String get filterMarkersTooltip => 'Filtruj znaczniki'; + + @override + String get contactsFilter => 'Kontakty'; + + @override + String get repeatersFilter => 'Przekaźniki'; + + @override + String get sarMarkers => 'Znaczniki SAR'; + + @override + String get foundPerson => 'Odnaleziona osoba'; + + @override + String get fire => 'Pożar'; + + @override + String get stagingArea => 'Punkt zbiórki'; + + @override + String get showAll => 'Pokaż wszystko'; + + @override + String get locationUnavailable => 'Lokalizacja niedostępna'; + + @override + String get ahead => 'przed tobą'; + + @override + String degreesRight(int degrees) { + return '$degrees° w prawo'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° w lewo'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Szer.: $latitude Dł.: $longitude'; + } + + @override + String get noContactsYet => 'Brak kontaktów'; + + @override + String get connectToDeviceToLoadContacts => + 'Połącz się z urządzeniem, aby wczytać kontakty'; + + @override + String get teamMembers => 'Członkowie zespołu'; + + @override + String get repeaters => 'Przekaźniki'; + + @override + String get rooms => 'Pokoje'; + + @override + String get channels => 'Kanały'; + + @override + String get selectMapLayer => 'Wybierz warstwę mapy'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'Satelita ESRI'; + + @override + String get googleHybrid => 'Google Hybrydowa'; + + @override + String get googleRoadmap => 'Google Drogowa'; + + @override + String get googleTerrain => 'Google Teren'; + + @override + String get dragToPosition => 'Przeciągnij do pozycji'; + + @override + String get createSarMarker => 'Utwórz znacznik SAR'; + + @override + String get compass => 'Kompas'; + + @override + String get navigationAndContacts => 'Nawigacja i kontakty'; + + @override + String get sarAlert => 'ALERT SAR'; + + @override + String get textCopiedToClipboard => 'Tekst skopiowano do schowka'; + + @override + String get cannotReplySenderMissing => + 'Nie można odpowiedzieć: brak informacji o nadawcy'; + + @override + String get cannotReplyContactNotFound => + 'Nie można odpowiedzieć: nie znaleziono kontaktu'; + + @override + String get copyText => 'Kopiuj tekst'; + + @override + String get saveAsTemplate => 'Zapisz jako szablon'; + + @override + String get templateSaved => 'Szablon zapisano pomyślnie'; + + @override + String get templateAlreadyExists => 'Szablon z tym emoji już istnieje'; + + @override + String get deleteMessage => 'Usuń wiadomość'; + + @override + String get deleteMessageConfirmation => + 'Czy na pewno chcesz usunąć tę wiadomość?'; + + @override + String get shareLocation => 'Udostępnij lokalizację'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nWspółrzędne: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'Lokalizacja SAR'; + + @override + String get justNow => 'Przed chwilą'; + + @override + String minutesAgo(int minutes) { + return '$minutes min temu'; + } + + @override + String hoursAgo(int hours) { + return '$hours godz. temu'; + } + + @override + String daysAgo(int days) { + return '$days dni temu'; + } + + @override + String secondsAgo(int seconds) { + return '$seconds sek. temu'; + } + + @override + String get sending => 'Wysyłanie...'; + + @override + String get sent => 'Wysłano'; + + @override + String get delivered => 'Dostarczono'; + + @override + String deliveredWithTime(int time) { + return 'Dostarczono (${time}ms)'; + } + + @override + String get failed => 'Niepowodzenie'; + + @override + String get broadcast => 'Nadawanie'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Dostarczono do $delivered/$total kontaktów'; + } + + @override + String get allDelivered => 'Wszystko dostarczono'; + + @override + String get recipientDetails => 'Szczegóły odbiorcy'; + + @override + String get pending => 'Oczekujące'; + + @override + String get sarMarkerFoundPerson => 'Odnaleziona osoba'; + + @override + String get sarMarkerFire => 'Miejsce pożaru'; + + @override + String get sarMarkerStagingArea => 'Strefa zbiórki'; + + @override + String get sarMarkerObject => 'Odnaleziony obiekt'; + + @override + String get from => 'Od'; + + @override + String get coordinates => 'Współrzędne'; + + @override + String get tapToViewOnMap => 'Dotknij, aby zobaczyć na mapie'; + + @override + String get radioSettings => 'Ustawienia radia'; + + @override + String get frequencyMHz => 'Częstotliwość (MHz)'; + + @override + String get bandwidth => 'Szerokość pasma'; + + @override + String get spreadingFactor => 'Współczynnik rozpraszania'; + + @override + String get codingRate => 'Szybkość kodowania'; + + @override + String get txPowerDbm => 'Moc TX (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Maks: $power dBm'; + } + + @override + String get you => 'Ty'; + + @override + String exportFailed(String error) { + return 'Eksport nie powiódł się: $error'; + } + + @override + String importFailed(String error) { + return 'Import nie powiódł się: $error'; + } + + @override + String get unknown => 'Nieznane'; + + @override + String get onlineLayers => 'Warstwy online'; + + @override + String get locationTrail => 'Ślad lokalizacji'; + + @override + String get showTrailOnMap => 'Pokaż ślad na mapie'; + + @override + String get trailVisible => 'Ślad jest widoczny na mapie'; + + @override + String get trailHiddenRecording => 'Ślad jest ukryty (nadal nagrywany)'; + + @override + String get duration => 'Czas trwania'; + + @override + String get points => 'Punkty'; + + @override + String get clearTrail => 'Wyczyść ślad'; + + @override + String get clearTrailQuestion => 'Wyczyścić ślad?'; + + @override + String get clearTrailConfirmation => + 'Czy na pewno chcesz wyczyścić bieżący ślad lokalizacji? Tego działania nie można cofnąć.'; + + @override + String get noTrailRecorded => 'Brak zapisanego śladu'; + + @override + String get startTrackingToRecord => + 'Rozpocznij śledzenie lokalizacji, aby zapisać ślad'; + + @override + String get trailControls => 'Sterowanie śladem'; + + @override + String get model => 'Model'; + + @override + String get version => 'Wersja'; + + @override + String get maxContacts => 'Maks. kontaktów'; + + @override + String get maxChannels => 'Maks. kanałów'; + + @override + String get publicInfo => 'Informacje publiczne'; + + @override + String get useCurrentLocation => 'Użyj bieżącej lokalizacji'; + + @override + String get noneUnknown => 'Brak/Nieznane'; + + @override + String get chatNode => 'Węzeł czatu'; + + @override + String get repeater => 'Przekaźnik'; + + @override + String get roomChannel => 'Pokój/Kanał'; + + @override + String typeNumber(int number) { + return 'Typ $number'; + } + + @override + String failedToSave(String error) { + return 'Nie udało się zapisać: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Nie udało się pobrać lokalizacji: $error'; + } + + @override + String get sarTemplates => 'Szablony SAR'; + + @override + String get addTemplate => 'Dodaj szablon'; + + @override + String get editTemplate => 'Edytuj szablon'; + + @override + String get deleteTemplate => 'Usuń szablon'; + + @override + String get templateName => 'Nazwa szablonu'; + + @override + String get templateNameHint => 'np. Odnaleziona osoba'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji jest wymagane'; + + @override + String get nameRequired => 'Nazwa jest wymagana'; + + @override + String get templateDescription => 'Opis (opcjonalnie)'; + + @override + String get templateDescriptionHint => 'Dodaj dodatkowy kontekst...'; + + @override + String get templateColor => 'Kolor'; + + @override + String get previewFormat => 'Podgląd (format wiadomości SAR)'; + + @override + String get importFromClipboard => 'Importuj'; + + @override + String get exportToClipboard => 'Eksportuj'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Usunąć szablon „$name”?'; + } + + @override + String get templateAdded => 'Dodano szablon'; + + @override + String get templateUpdated => 'Zaktualizowano szablon'; + + @override + String get templateDeleted => 'Usunięto szablon'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Zaimportowano $count szablonów', + one: 'Zaimportowano 1 szablon', + zero: 'Nie zaimportowano szablonów', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Wyeksportowano $count szablonów do schowka', + one: 'Wyeksportowano 1 szablon do schowka', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Przywróć domyślne'; + + @override + String get resetToDefaultsConfirmation => + 'To usunie wszystkie własne szablony i przywróci 4 domyślne szablony. Kontynuować?'; + + @override + String get reset => 'Resetuj'; + + @override + String get resetComplete => 'Szablony przywrócono do domyślnych'; + + @override + String get noTemplates => 'Brak dostępnych szablonów'; + + @override + String get tapAddToCreate => 'Dotknij +, aby utworzyć pierwszy szablon'; + + @override + String get ok => 'OK'; + + @override + String get locationPermission => 'Uprawnienie lokalizacji'; + + @override + String get checking => 'Sprawdzanie...'; + + @override + String get locationPermissionGrantedAlways => 'Przyznano (zawsze)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Przyznano (podczas użycia)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Odrzucono - dotknij, aby poprosić'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Trwale odrzucono - otwórz ustawienia'; + + @override + String get locationPermissionDialogContent => + 'Dostęp do lokalizacji został trwale odrzucony. Włącz go w ustawieniach urządzenia, aby używać GPS i udostępniania lokalizacji.'; + + @override + String get openSettings => 'Otwórz ustawienia'; + + @override + String get locationPermissionGranted => 'Przyznano uprawnienie lokalizacji!'; + + @override + String get locationPermissionRequiredForGps => + 'Dostęp do lokalizacji jest wymagany do śledzenia GPS i udostępniania lokalizacji.'; + + @override + String get locationPermissionAlreadyGranted => + 'Dostęp do lokalizacji został już przyznany.'; + + @override + String get sarNavyBlue => 'SAR Granatowy'; + + @override + String get sarNavyBlueDescription => 'Tryb profesjonalny/operacyjny'; + + @override + String get selectRecipient => 'Wybierz odbiorcę'; + + @override + String get broadcastToAllNearby => 'Nadaj do wszystkich w pobliżu'; + + @override + String get searchRecipients => 'Szukaj odbiorców...'; + + @override + String get noContactsFound => 'Nie znaleziono kontaktów'; + + @override + String get noRoomsFound => 'Nie znaleziono pokoi'; + + @override + String get noRecipientsAvailable => 'Brak dostępnych odbiorców'; + + @override + String get noChannelsFound => 'Nie znaleziono kanałów'; + + @override + String get newMessage => 'Nowa wiadomość'; + + @override + String get channel => 'Kanał'; + + @override + String get samplePoliceLead => 'Dowódca policji'; + + @override + String get sampleDroneOperator => 'Operator drona'; + + @override + String get sampleFirefighterAlpha => 'Strażak'; + + @override + String get sampleMedicCharlie => 'Ratownik medyczny'; + + @override + String get sampleCommandDelta => 'Dowództwo'; + + @override + String get sampleFireEngine => 'Wóz strażacki'; + + @override + String get sampleAirSupport => 'Wsparcie lotnicze'; + + @override + String get sampleBaseCoordinator => 'Koordynator bazy'; + + @override + String get channelEmergency => 'Alarmowy'; + + @override + String get channelCoordination => 'Koordynacja'; + + @override + String get channelUpdates => 'Aktualizacje'; + + @override + String get sampleTeamMember => 'Przykładowy członek zespołu'; + + @override + String get sampleScout => 'Przykładowy zwiadowca'; + + @override + String get sampleBase => 'Przykładowa baza'; + + @override + String get sampleSearcher => 'Przykładowy poszukiwacz'; + + @override + String get sampleObjectBackpack => ' Znaleziono plecak - kolor niebieski'; + + @override + String get sampleObjectVehicle => ' Porzucony pojazd - sprawdzić właściciela'; + + @override + String get sampleObjectCamping => ' Znaleziono sprzęt kempingowy'; + + @override + String get sampleObjectTrailMarker => + ' Znaleziono znacznik szlaku poza trasą'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Wszystkie zespoły - meldunek'; + + @override + String get sampleMsgWeatherUpdate => + 'Aktualizacja pogody: bezchmurnie, temp. 18°C'; + + @override + String get sampleMsgBaseCamp => 'Bazę założono w strefie zbiórki'; + + @override + String get sampleMsgTeamAlpha => 'Zespół przemieszcza się do sektora 2'; + + @override + String get sampleMsgRadioCheck => + 'Kontrola radiowa - wszystkie stacje proszone o odpowiedź'; + + @override + String get sampleMsgWaterSupply => 'Woda dostępna w punkcie kontrolnym 3'; + + @override + String get sampleMsgTeamBravo => 'Zespół melduje: sektor 1 czysty'; + + @override + String get sampleMsgEtaRallyPoint => + 'Szacowany czas dotarcia do punktu zbiórki: 15 minut'; + + @override + String get sampleMsgSupplyDrop => 'Zrzut zaopatrzenia potwierdzony na 14:00'; + + @override + String get sampleMsgDroneSurvey => + 'Rozpoznanie dronem zakończone - brak wyników'; + + @override + String get sampleMsgTeamCharlie => 'Zespół prosi o wsparcie'; + + @override + String get sampleMsgRadioDiscipline => + 'Wszystkie jednostki: zachować dyscyplinę radiową'; + + @override + String get sampleMsgUrgentMedical => + 'PILNE: potrzebna pomoc medyczna w sektorze 4'; + + @override + String get sampleMsgAdultMale => ' Dorosły mężczyzna, przytomny'; + + @override + String get sampleMsgFireSpotted => 'Wykryto pożar - współrzędne w drodze'; + + @override + String get sampleMsgSpreadingRapidly => ' Szybko się rozprzestrzenia!'; + + @override + String get sampleMsgPriorityHelicopter => + 'PRIORYTET: potrzebne wsparcie śmigłowca'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Zespół medyczny jest w drodze do twojej lokalizacji'; + + @override + String get sampleMsgEvacHelicopter => 'ETA śmigłowca ewakuacyjnego 10 minut'; + + @override + String get sampleMsgEmergencyResolved => + 'Sytuacja opanowana - wszystko jasne'; + + @override + String get sampleMsgEmergencyStagingArea => ' Strefa zbiórki awaryjnej'; + + @override + String get sampleMsgEmergencyServices => + 'Służby ratunkowe zostały powiadomione i jadą na miejsce'; + + @override + String get sampleAlphaTeamLead => 'Lider zespołu'; + + @override + String get sampleBravoScout => 'Zwiadowca'; + + @override + String get sampleCharlieMedic => 'Ratownik medyczny'; + + @override + String get sampleDeltaNavigator => 'Nawigator'; + + @override + String get sampleEchoSupport => 'Wsparcie'; + + @override + String get sampleBaseCommand => 'Dowództwo bazy'; + + @override + String get sampleFieldCoordinator => 'Koordynator terenowy'; + + @override + String get sampleMedicalTeam => 'Zespół medyczny'; + + @override + String get mapDrawing => 'Rysunek mapy'; + + @override + String get navigateToDrawing => 'Nawiguj do rysunku'; + + @override + String get copyCoordinates => 'Kopiuj współrzędne'; + + @override + String get hideFromMap => 'Ukryj na mapie'; + + @override + String get lineDrawing => 'Rysunek linii'; + + @override + String get rectangleDrawing => 'Rysunek prostokąta'; + + @override + String get manualCoordinates => 'Współrzędne ręczne'; + + @override + String get enterCoordinatesManually => 'Wprowadź współrzędne ręcznie'; + + @override + String get latitudeLabel => 'Szerokość geograficzna'; + + @override + String get longitudeLabel => 'Długość geograficzna'; + + @override + String get exampleCoordinates => 'Przykład: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Udostępnij rysunek'; + + @override + String get shareWithAllNearbyDevices => + 'Udostępnij wszystkim pobliskim urządzeniom'; + + @override + String get shareToRoom => 'Udostępnij do pokoju'; + + @override + String get sendToPersistentStorage => 'Wyślij do trwałego magazynu pokoju'; + + @override + String get deleteDrawingConfirm => 'Czy na pewno chcesz usunąć ten rysunek?'; + + @override + String get drawingDeleted => 'Rysunek usunięto'; + + @override + String yourDrawingsCount(int count) { + return 'Twoje rysunki ($count)'; + } + + @override + String get shared => 'Udostępniono'; + + @override + String get line => 'Linia'; + + @override + String get rectangle => 'Prostokąt'; + + @override + String get updateAvailable => 'Dostępna aktualizacja'; + + @override + String get currentVersion => 'Bieżąca'; + + @override + String get latestVersion => 'Najnowsza'; + + @override + String get downloadUpdate => 'Pobierz'; + + @override + String get updateLater => 'Później'; + + @override + String get cadastralParcels => 'Działki katastralne'; + + @override + String get forestRoads => 'Drogi leśne'; + + @override + String get wmsOverlays => 'Nakładki WMS'; + + @override + String get hikingTrails => 'Szlaki piesze'; + + @override + String get mainRoads => 'Drogi główne'; + + @override + String get houseNumbers => 'Numery budynków'; + + @override + String get fireHazardZones => 'Strefy zagrożenia pożarowego'; + + @override + String get historicalFires => 'Historyczne pożary'; + + @override + String get firebreaks => 'Pasy przeciwpożarowe'; + + @override + String get krasFireZones => 'Strefy pożarowe Krasu'; + + @override + String get placeNames => 'Nazwy miejsc'; + + @override + String get municipalityBorders => 'Granice gmin'; + + @override + String get topographicMap => 'Mapa topograficzna 1:25000'; + + @override + String get recentMessages => 'Ostatnie wiadomości'; + + @override + String get addChannel => 'Dodaj kanał'; + + @override + String get channelName => 'Nazwa kanału'; + + @override + String get channelNameHint => 'np. Zespół Ratunkowy Alfa'; + + @override + String get channelSecret => 'Sekret kanału'; + + @override + String get channelSecretHint => 'Wspólne hasło dla tego kanału'; + + @override + String get channelSecretHelp => + 'Ten sekret musi być współdzielony ze wszystkimi członkami zespołu, którzy potrzebują dostępu do tego kanału'; + + @override + String get channelTypesInfo => + 'Kanały hash (#team): sekret jest generowany automatycznie z nazwy. Ta sama nazwa = ten sam kanał na wszystkich urządzeniach.\n\nKanały prywatne: użyj jawnego sekretu. Dołączyć mogą tylko osoby znające sekret.'; + + @override + String get hashChannelInfo => + 'Kanał hash: sekret zostanie wygenerowany automatycznie z nazwy kanału. Każdy używający tej samej nazwy dołączy do tego samego kanału.'; + + @override + String get channelNameRequired => 'Nazwa kanału jest wymagana'; + + @override + String get channelNameTooLong => + 'Nazwa kanału może mieć maksymalnie 31 znaków'; + + @override + String get channelSecretRequired => 'Sekret kanału jest wymagany'; + + @override + String get channelSecretTooLong => + 'Sekret kanału może mieć maksymalnie 32 znaki'; + + @override + String get invalidAsciiCharacters => 'Dozwolone są tylko znaki ASCII'; + + @override + String get channelCreatedSuccessfully => 'Kanał utworzono pomyślnie'; + + @override + String channelCreationFailed(String error) { + return 'Nie udało się utworzyć kanału: $error'; + } + + @override + String get deleteChannel => 'Usuń kanał'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Czy na pewno chcesz usunąć kanał „$channelName”? Tej operacji nie można cofnąć.'; + } + + @override + String get channelDeletedSuccessfully => 'Kanał usunięto pomyślnie'; + + @override + String channelDeletionFailed(String error) { + return 'Nie udało się usunąć kanału: $error'; + } + + @override + String get createChannel => 'Utwórz kanał'; + + @override + String get wizardBack => 'Wstecz'; + + @override + String get wizardSkip => 'Pomiń'; + + @override + String get wizardNext => 'Dalej'; + + @override + String get wizardGetStarted => 'Zaczynaj'; + + @override + String get wizardWelcomeTitle => 'Witamy w MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Pokaż samouczek powitalny'; + + @override + String get allTeamContacts => 'Wszystkie kontakty zespołu'; + + @override + String directMessagesInfo(int count) { + return 'Wiadomości bezpośrednie z ACK. Wysłano do $count członków zespołu.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'Znacznik SAR wysłano do $count kontaktów'; + } + + @override + String get noContactsAvailable => 'Brak kontaktów zespołu'; + + @override + String get reply => 'Odpowiedz'; + + @override + String get technicalDetails => 'Szczegóły techniczne'; + + @override + String get messageTechnicalDetails => 'Szczegóły techniczne wiadomości'; + + @override + String get linkQuality => 'Jakość łącza'; + + @override + String get delivery => 'Dostarczenie'; + + @override + String get status => 'Status'; + + @override + String get expectedAckTag => 'Oczekiwany znacznik ACK'; + + @override + String get roundTrip => 'Czas podróży w obie strony'; + + @override + String get retryAttempt => 'Próba ponowienia'; + + @override + String get floodFallback => 'Awaryjny flooding'; + + @override + String get identity => 'Tożsamość'; + + @override + String get messageId => 'ID wiadomości'; + + @override + String get sender => 'Nadawca'; + + @override + String get senderKey => 'Klucz nadawcy'; + + @override + String get recipient => 'Odbiorca'; + + @override + String get recipientKey => 'Klucz odbiorcy'; + + @override + String get voice => 'Głos'; + + @override + String get voiceId => 'ID głosu'; + + @override + String get envelope => 'Koperta'; + + @override + String get sessionProgress => 'Postęp sesji'; + + @override + String get complete => 'Zakończono'; + + @override + String get rawDump => 'Surowy zrzut'; + + @override + String get cannotRetryMissingRecipient => + 'Nie można ponowić: brak informacji o odbiorcy'; + + @override + String get voiceUnavailable => 'Głos jest obecnie niedostępny'; + + @override + String get requestingVoice => 'Pobieranie głosu'; + + @override + String get device => 'urządzenie'; + + @override + String get change => 'Zmień'; + + @override + String get wizardOverviewDescription => + 'Ta aplikacja łączy wiadomości MeshCore, terenowe aktualizacje SAR, mapy i narzędzia urządzenia w jednym miejscu.'; + + @override + String get wizardOverviewFeature1 => + 'Wysyłaj wiadomości bezpośrednie, wpisy pokojowe i wiadomości kanałowe z głównej karty Wiadomości.'; + + @override + String get wizardOverviewFeature2 => + 'Udostępniaj znaczniki SAR, rysunki mapy, klipy głosowe i obrazy przez sieć mesh.'; + + @override + String get wizardOverviewFeature3 => + 'Połącz się przez BLE lub TCP, a następnie zarządzaj radiem towarzyszącym bezpośrednio z aplikacji.'; + + @override + String get wizardMessagingTitle => 'Wiadomości i raporty terenowe'; + + @override + String get wizardMessagingDescription => + 'Wiadomości to tutaj coś więcej niż zwykły tekst. Aplikacja obsługuje już kilka typów danych operacyjnych i przepływów transferu.'; + + @override + String get wizardMessagingFeature1 => + 'Wysyłaj wiadomości bezpośrednie, wpisy pokojowe i ruch kanałowy z jednego edytora.'; + + @override + String get wizardMessagingFeature2 => + 'Twórz aktualizacje SAR i wielokrotnego użytku szablony SAR do typowych raportów terenowych.'; + + @override + String get wizardMessagingFeature3 => + 'Przesyłaj sesje głosowe i obrazy z postępem i szacowanym czasem transmisji w interfejsie.'; + + @override + String get wizardConnectDeviceTitle => 'Połącz urządzenie'; + + @override + String get wizardConnectDeviceDescription => + 'Podłącz radio MeshCore, wybierz nazwę i zastosuj profil radiowy przed kontynuacją.'; + + @override + String get wizardSetupBadge => 'Konfiguracja'; + + @override + String get wizardOverviewBadge => 'Przegląd'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Połączono z $deviceName'; + } + + @override + String get wizardNoDeviceConnected => 'Brak podłączonego urządzenia'; + + @override + String get wizardSkipForNow => 'Pomiń na razie'; + + @override + String get wizardDeviceNameLabel => 'Nazwa urządzenia'; + + @override + String get wizardDeviceNameHelp => + 'Ta nazwa jest rozgłaszana innym użytkownikom MeshCore.'; + + @override + String get wizardConfigRegionLabel => 'Region konfiguracji'; + + @override + String get wizardConfigRegionHelp => + 'Używa pełnej oficjalnej listy presetów MeshCore. Domyślnie wybrane jest EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Upewnij się, że wybrany preset jest zgodny z lokalnymi przepisami radiowymi.'; + + @override + String get wizardPresetNote2 => + 'Lista odpowiada oficjalnemu źródłu presetów narzędzia konfiguracji MeshCore.'; + + @override + String get wizardPresetNote3 => + 'EU/UK (Narrow) pozostaje domyślnie wybrane podczas wdrożenia.'; + + @override + String get wizardSaving => 'Zapisywanie...'; + + @override + String get wizardSaveAndContinue => 'Zapisz i kontynuuj'; + + @override + String get wizardEnterDeviceName => + 'Wprowadź nazwę urządzenia przed kontynuacją.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return 'Zapisano $deviceName z ustawieniem $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Kontakty, pokoje i repeatery'; + + @override + String get wizardNetworkDescription => + 'Karta Kontakty porządkuje sieć, którą odkrywasz, oraz trasy, których uczysz się z czasem.'; + + @override + String get wizardNetworkFeature1 => + 'Przeglądaj członków zespołu, repeatery, pokoje, kanały i oczekujące ogłoszenia na jednej liście.'; + + @override + String get wizardNetworkFeature2 => + 'Używaj smart ping, logowania do pokoi, poznanych ścieżek i narzędzi resetowania tras, gdy łączność staje się chaotyczna.'; + + @override + String get wizardNetworkFeature3 => + 'Twórz kanały i zarządzaj celami sieciowymi bez opuszczania aplikacji.'; + + @override + String get wizardMapOpsTitle => 'Mapa, ślady i współdzielona geometria'; + + @override + String get wizardMapOpsDescription => + 'Mapa aplikacji jest bezpośrednio powiązana z wiadomościami, śledzeniem i nakładkami SAR zamiast być osobnym podglądem.'; + + @override + String get wizardMapOpsFeature1 => + 'Śledź własną pozycję, lokalizacje członków zespołu i ślady ruchu na mapie.'; + + @override + String get wizardMapOpsFeature2 => + 'Otwieraj rysunki z wiadomości, podglądaj je w miejscu i usuwaj z mapy w razie potrzeby.'; + + @override + String get wizardMapOpsFeature3 => + 'Korzystaj z widoków map repeaterów i współdzielonych nakładek, aby zrozumieć zasięg sieci w terenie.'; + + @override + String get wizardToolsTitle => 'Narzędzia poza wiadomościami'; + + @override + String get wizardToolsDescription => + 'Jest tu więcej niż cztery główne karty. Aplikacja obejmuje także konfigurację, diagnostykę i opcjonalne przepływy czujników.'; + + @override + String get wizardToolsFeature1 => + 'Otwórz konfigurację urządzenia, aby zmienić ustawienia radia, telemetrię, moc TX i szczegóły urządzenia towarzyszącego.'; + + @override + String get wizardToolsFeature2 => + 'Włącz kartę Czujniki, gdy chcesz mieć pulpity monitorowanych czujników i szybkie odświeżanie.'; + + @override + String get wizardToolsFeature3 => + 'Używaj logów pakietów, skanowania widma i diagnostyki deweloperskiej podczas rozwiązywania problemów z mesh.'; + + @override + String get postConnectDiscoveryTitle => 'Odkryć przekaźniki teraz?'; + + @override + String get postConnectDiscoveryDescription => + 'Uruchom odkrywanie przekaźników zaraz po połączeniu, aby zobaczyć pobliskie węzły MeshCore i szybciej dodać je do sieci.'; + + @override + String get contactInSensors => 'W Czujnikach'; + + @override + String get contactAddToSensors => 'Dodaj do Czujników'; + + @override + String get contactSetPath => 'Ustaw ścieżkę'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName dodano do Czujników'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Nie udało się wyczyścić trasy: $error'; + } + + @override + String get contactRouteCleared => 'Trasa wyczyszczona'; + + @override + String contactRouteSet(String route) { + return 'Ustawiono trasę: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Nie udało się ustawić trasy: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'Limit czasu ACK'; + + @override + String get opcode => 'Opcode'; + + @override + String get payload => 'Ładunek'; + + @override + String get hops => 'Skoki'; + + @override + String get hashSize => 'Rozmiar hash'; + + @override + String get pathBytes => 'Bajty ścieżki'; + + @override + String get selectedPath => 'Wybrana ścieżka'; + + @override + String get estimatedTx => 'Szacowane nadawanie'; + + @override + String get senderToReceipt => 'Od nadawcy do odbioru'; + + @override + String get receivedCopies => 'Otrzymane kopie'; + + @override + String get retryCause => 'Przyczyna ponowienia'; + + @override + String get retryMode => 'Tryb ponowienia'; + + @override + String get retryResult => 'Wynik ponowienia'; + + @override + String get lastRetry => 'Ostatnia próba'; + + @override + String get rxPackets => 'Pakiety RX'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Szybkość'; + + @override + String get window => 'Okno'; + + @override + String get posttxDelay => 'Opóźnienie po nadawaniu'; + + @override + String get bandpass => 'Pasmowy'; + + @override + String get bandpassFilterVoice => 'Filtr pasmowy głosu'; + + @override + String get active => 'Aktywny'; + + @override + String get addContact => 'Dodaj kontakt'; + + @override + String get all => 'Wszystko'; + + @override + String get clearAllLabel => 'Wyczyść wszystko'; + + @override + String get clearFilters => 'Wyczyść filtry'; + + @override + String get clearRoute => 'Wyczyść trasę'; + + @override + String get clearMessages => 'Wyczyść wiadomości'; + + @override + String get clearScale => 'Wyczyść skalę'; + + @override + String get clearDiscoveries => 'Wyczyść odkrycia'; + + @override + String get clearOnlineTraceDatabase => 'Wyczyść bazę śladów'; + + @override + String get clearAllChannels => 'Wyczyść wszystkie kanały'; + + @override + String get clearAllContacts => 'Wyczyść wszystkie kontakty'; + + @override + String get clearChannels => 'Wyczyść kanały'; + + @override + String get clearContacts => 'Wyczyść kontakty'; + + @override + String get clearPathOnMaxRetry => 'Wyczyść ścieżkę przy maks. próbie'; + + @override + String get create => 'Utwórz'; + + @override + String get custom => 'Niestandardowy'; + + @override + String get defaultValue => 'Domyślny'; + + @override + String get duplicate => 'Duplikuj'; + + @override + String get editName => 'Edytuj nazwę'; + + @override + String get open => 'Otwórz'; + + @override + String get paste => 'Wklej'; + + @override + String get preview => 'Podgląd'; + + @override + String get remove => 'Usuń'; + + @override + String get rename => 'Zmień nazwę'; + + @override + String get resolveAll => 'Rozwiąż wszystko'; + + @override + String get send => 'Wyślij'; + + @override + String get sendAnyway => 'Wyślij mimo to'; + + @override + String get share => 'Udostępnij'; + + @override + String get trace => 'Śledzenie'; + + @override + String get discovery => 'Wykrywanie'; + + @override + String get discoverRepeaters => 'Wykryj przekaźniki'; + + @override + String get discoverSensors => 'Wykryj czujniki'; + + @override + String get repeaterDiscoverySent => 'Wykrywanie przekaźników wysłane'; + + @override + String get sensorDiscoverySent => 'Wykrywanie czujników wysłane'; + + @override + String get clearedPendingDiscoveries => 'Wyczyszczono oczekujące odkrycia.'; + + @override + String get autoDiscovery => 'Automatyczne wykrywanie'; + + @override + String get enableAutomaticAdding => 'Włącz automatyczne dodawanie'; + + @override + String get autoaddRepeaters => 'Automatycznie dodaj przekaźniki'; + + @override + String get autoaddRoomServers => 'Automatycznie dodaj serwery pokoi'; + + @override + String get autoaddSensors => 'Automatycznie dodaj czujniki'; + + @override + String get autoaddUsers => 'Automatycznie dodaj użytkowników'; + + @override + String get overwriteOldestWhenFull => 'Nadpisz najstarsze gdy pełne'; + + @override + String get storage => 'Pamięć'; + + @override + String get dangerZone => 'Strefa zagrożenia'; + + @override + String get profiles => 'Profile'; + + @override + String get favourites => 'Ulubione'; + + @override + String get sensors => 'Czujniki'; + + @override + String get others => 'Inne'; + + @override + String get gpsModule => 'Moduł GPS'; + + @override + String get liveTraffic => 'Ruch na żywo'; + + @override + String get repeatersMap => 'Mapa przekaźników'; + + @override + String get blePacketLogs => 'Logi pakietów BLE'; + + @override + String get onlineTraceDatabase => 'Baza śladów'; + + @override + String get routePathByteSize => 'Rozmiar ścieżki w bajtach'; + + @override + String get messageNotifications => 'Powiadomienia o wiadomościach'; + + @override + String get sarAlerts => 'Alerty SAR'; + + @override + String get discoveryNotifications => 'Powiadomienia o wykrywaniu'; + + @override + String get updateNotifications => 'Powiadomienia o aktualizacjach'; + + @override + String get muteWhileAppIsOpen => 'Wycisz gdy aplikacja jest otwarta'; + + @override + String get disableContacts => 'Wyłącz kontakty'; + + @override + String get enableSensorsTab => 'Włącz kartę Czujniki'; + + @override + String get enableProfiles => 'Włącz profile'; + + @override + String get nearestRepeaterFallback => 'Najbliższy przekaźnik awaryjny'; + + @override + String get deleteAllStoredMessageHistory => 'Usuń całą historię wiadomości'; + + @override + String get messageFontSize => 'Rozmiar czcionki wiadomości'; + + @override + String get rotateMapWithHeading => 'Obracaj mapę z kierunkiem'; + + @override + String get showMapDebugInfo => 'Pokaż info debugowania mapy'; + + @override + String get openMapInFullscreen => 'Otwórz mapę na pełnym ekranie'; + + @override + String get showSarMarkersLabel => 'Pokaż znaczniki SAR'; + + @override + String get hideRepeatersOnMap => 'Ukryj przekaźniki na mapie'; + + @override + String get setMapScale => 'Ustaw skalę mapy'; + + @override + String get customMapScaleSaved => 'Niestandardowa skala mapy zapisana'; + + @override + String get voiceBitrate => 'Bitrate głosu'; + + @override + String get voiceCompressor => 'Kompresor głosu'; + + @override + String get voiceLimiter => 'Limiter głosu'; + + @override + String get micAutoGain => 'Automatyczne wzmocnienie mikrofonu'; + + @override + String get echoCancellation => 'Usuwanie echa'; + + @override + String get noiseSuppression => 'Tłumienie szumu'; + + @override + String get trimSilenceInVoiceMessages => + 'Przytnij ciszę w wiadomościach głosowych'; + + @override + String get compressor => 'Kompresor'; + + @override + String get limiter => 'Limiter'; + + @override + String get autoGain => 'Automatyczne wzmocnienie'; + + @override + String get echoCancel => 'Echo'; + + @override + String get noiseSuppress => 'Szum'; + + @override + String get silenceTrim => 'Cisza'; + + @override + String get maxImageSize => 'Maksymalny rozmiar obrazu'; + + @override + String get imageCompression => 'Kompresja obrazu'; + + @override + String get grayscale => 'Skala szarości'; + + @override + String get ultraMode => 'Tryb ultra'; + + @override + String get fastPrivateGpsUpdates => 'Szybkie prywatne aktualizacje GPS'; + + @override + String get movementThreshold => 'Próg ruchu'; + + @override + String get fastGpsMovementThreshold => 'Próg ruchu szybkiego GPS'; + + @override + String get fastGpsActiveuseInterval => + 'Interwał aktywnego użycia szybkiego GPS'; + + @override + String get activeuseUpdateInterval => + 'Interwał aktualizacji przy aktywnym użyciu'; + + @override + String get repeatNearbyTraffic => 'Powtarzaj pobliski ruch'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Przekazuj przez przekaźniki w sieci'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Tylko w pobliżu, bez zalewania przekaźników'; + + @override + String get multihop => 'Wieloskokowy'; + + @override + String get createProfile => 'Utwórz profil'; + + @override + String get renameProfile => 'Zmień nazwę profilu'; + + @override + String get newProfile => 'Nowy profil'; + + @override + String get manageProfiles => 'Zarządzaj profilami'; + + @override + String get enableProfilesToStartManagingThem => + 'Włącz profile, aby zacząć nimi zarządzać.'; + + @override + String get openMessage => 'Otwórz wiadomość'; + + @override + String get jumpToTheRelatedSarMessage => + 'Przejdź do powiązanej wiadomości SAR'; + + @override + String get removeSarMarker => 'Usuń znacznik SAR'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Wybierz cel wysłania znacznika SAR'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'Znacznik SAR wysłany na kanał publiczny'; + + @override + String get sarMarkerSentToRoom => 'Znacznik SAR wysłany do pokoju'; + + @override + String get loadFromGallery => 'Załaduj z galerii'; + + @override + String get replaceImage => 'Zamień obraz'; + + @override + String get selectFromGallery => 'Wybierz z galerii'; + + @override + String get team => 'Zespół'; + + @override + String get found => 'Znaleziono'; + + @override + String get staging => 'Punkt zbiórki'; + + @override + String get object => 'Obiekt'; + + @override + String get quiet => 'Cicho'; + + @override + String get busy => 'Zajęty'; + + @override + String get searchMessages => 'Szukaj wiadomości'; + + @override + String get sendImageFromGallery => 'Wyślij obraz z galerii'; + + @override + String get takePhoto => 'Zrób zdjęcie'; + + @override + String get allMessages => 'Wszystkie wiadomości'; + + @override + String get sendToPublicChannel => 'Wysłać na kanał publiczny?'; + + @override + String get selectMarkerTypeAndDestination => 'Wybierz typ znacznika i cel'; + + @override + String get noDestinationsAvailableLabel => 'Brak dostępnych celów'; + + @override + String get image => 'Obraz'; + + @override + String get format => 'Format'; + + @override + String get dimensions => 'Wymiary'; + + @override + String get segments => 'Segmenty'; + + @override + String get transfers => 'Transfery'; + + @override + String get downloadedBy => 'Pobrane przez'; + + @override + String get saveDiscoverySettings => 'Zapisz ustawienia wykrywania'; + + @override + String get savePublicInfo => 'Zapisz informacje publiczne'; + + @override + String get saveRadioSettings => 'Zapisz ustawienia radia'; + + @override + String get savePath => 'Zapisz ścieżkę'; + + @override + String get wipeDeviceData => 'Wyczyść dane urządzenia'; + + @override + String get wipeDevice => 'Wyczyść urządzenie'; + + @override + String get destructiveDeviceActions => 'Destrukcyjne akcje na urządzeniu.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Wybierz ustawienie wstępne lub dostosuj ustawienia radia.'; + + @override + String get used => 'Użyto'; + + @override + String get total => 'Razem'; + + @override + String get renameValue => 'Zmień nazwę wartości'; + + @override + String get customizeFields => 'Dostosuj pola'; + + @override + String get livePreview => 'Podgląd na żywo'; + + @override + String get refreshSchedule => 'Harmonogram odświeżania'; + + @override + String get noResponse => 'Brak odpowiedzi'; + + @override + String get refreshing => 'Odświeżanie'; + + @override + String get unavailable => 'Niedostępny'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Wybierz przekaźnik lub węzeł do obserwacji.'; + + @override + String get publicKeyLabel => 'Klucz publiczny'; + + @override + String get alreadyInContacts => 'Już w kontaktach'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Połącz się z urządzeniem przed dodaniem kontaktów'; + + @override + String get fromContacts => 'Z kontaktów'; + + @override + String get onlineOnly => 'Tylko online'; + + @override + String get inBoth => 'W obu'; + + @override + String get source => 'Źródło'; + + @override + String get allMessagesCleared => 'Wszystkie wiadomości wyczyszczone'; + + @override + String get onlineTraceDatabaseCleared => 'Baza śladów wyczyszczona'; + + @override + String get packetLogsCleared => 'Logi pakietów wyczyszczone'; + + @override + String get hexDataCopiedToClipboard => 'Dane hex skopiowane do schowka'; + + @override + String get developerModeEnabled => 'Tryb programisty włączony'; + + @override + String get developerModeDisabled => 'Tryb programisty wyłączony'; + + @override + String get clipboardIsEmpty => 'Schowek jest pusty'; + + @override + String get contactImported => 'Kontakt zaimportowany'; + + @override + String get contactLinkCopiedToClipboard => + 'Link kontaktu skopiowany do schowka'; + + @override + String get failedToExportContact => 'Nie udało się wyeksportować kontaktu'; + + @override + String get noLogsToExport => 'Brak logów do eksportu'; + + @override + String get exportAsCsv => 'Eksportuj jako CSV'; + + @override + String get exportAsText => 'Eksportuj jako tekst'; + + @override + String get receivedRfc3339 => 'Odebrano (RFC3339)'; + + @override + String get buildTime => 'Czas budowania'; + + @override + String get downloadUrlNotAvailable => 'URL pobierania niedostępny'; + + @override + String get cannotOpenDownloadUrl => 'Nie można otworzyć URL pobierania'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'Sprawdzanie aktualizacji dostępne tylko na Androidzie'; + + @override + String get youAreRunningTheLatestVersion => 'Używasz najnowszej wersji'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Aktualizacja dostępna, ale URL pobierania nie znaleziony'; + + @override + String get startTictactoe => 'Rozpocznij Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe niedostępny'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: przeciwnik nieznany'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: oczekiwanie na start'; + + @override + String get acceptsShareLinks => 'Akceptuje udostępnione linki'; + + @override + String get supportsRawHex => 'Obsługuje surowy hex'; + + @override + String get clipboardfriendly => 'Przyjazne dla schowka'; + + @override + String get captured => 'Przechwycone'; + + @override + String get size => 'Rozmiar'; + + @override + String get noCustomChannelsToClear => + 'Brak niestandardowych kanałów do wyczyszczenia.'; + + @override + String get noDeviceContactsToClear => + 'Brak kontaktów urządzenia do wyczyszczenia.'; + + @override + String get aToZ => 'A-Z'; + + @override + String get add => 'Dodaj'; + + @override + String get addAdditionalDetails => 'Dodaj dodatkowe szczegóły'; + + @override + String get addPeer => 'Dodaj węzeł'; + + @override + String get addPeerManually => 'Dodaj węzeł ręcznie'; + + @override + String addedToContacts(String name) { + return '$name dodano do kontaktów'; + } + + @override + String addedToSensors(String name) { + return '$name dodano do czujników'; + } + + @override + String get allowAll => 'Zezwól na wszystko'; + + @override + String get anonymousRxStats => 'Anonimowe statystyki RX'; + + @override + String get appearance => 'Wygląd'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Telemetria bazowa'; + + @override + String get blePin => 'PIN BLE'; + + @override + String get buzzerAlerts => 'Alerty brzęczyka'; + + @override + String get cancelImageReceive => 'Anuluj odbieranie obrazu'; + + @override + String get choose => 'Wybierz'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Wybierz, które zakładki i sekcje kontaktów mają być widoczne'; + + @override + String get clearCache => 'Wyczyść pamięć podręczną'; + + @override + String get clearHistory => 'Wyczyść historię'; + + @override + String get clearLiveView => 'Wyczyść widok na żywo'; + + @override + String get clearLogs => 'Wyczyść logi'; + + @override + String get clearOfflineCache => 'Wyczyścić pamięć podręczną offline?'; + + @override + String get clearOverlay => 'Wyczyść nakładkę'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Wyczyść trasę dopiero po wyczerpaniu wszystkich prób'; + + @override + String get codecBitrateAndAudioProcessing => + 'Kodek, bitrate i przetwarzanie dźwięku'; + + @override + String get connectByIpAddress => 'Połącz przez adres IP'; + + @override + String get controlWhichAlertsYouReceive => + 'Kontroluj, które alerty otrzymujesz'; + + @override + String get copyFullHex => 'Kopiuj pełny Hex'; + + @override + String get copyRawResponse => 'Kopiuj surową odpowiedź'; + + @override + String get couldNotLoadMetHistory => 'Nie udało się wczytać historii MET'; + + @override + String customizeSensor(String name) { + return 'Dostosuj $name'; + } + + @override + String get data => 'Dane'; + + @override + String get deleteGroup => 'Usuń grupę'; + + @override + String deleteStyleConfirm(String name) { + return 'Usunąć $name?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Usunąć ten $type?'; + } + + @override + String get deny => 'Odmów'; + + @override + String get deviceInfo => 'Informacje o urządzeniu'; + + @override + String get deviceSettings => 'Ustawienia urządzenia'; + + @override + String get deviceTimeSynced => + 'Czas urządzenia zsynchronizowany z tym telefonem.'; + + @override + String get disableFastGpsPublishing => 'Wyłącz szybkie publikowanie GPS'; + + @override + String get displayMarkersAndTraceDatabase => + 'Wyświetlanie, znaczniki i baza danych tras'; + + @override + String get download => 'Pobierz'; + + @override + String get environmentalTelemetry => 'Telemetria środowiskowa'; + + @override + String errorCheckingForUpdates(String error) { + return 'Błąd sprawdzania aktualizacji: $error'; + } + + @override + String errorGeneric(String error) { + return 'Błąd: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Błąd otwierania pobranego pliku: $error'; + } + + @override + String get exportLogs => 'Eksportuj logi'; + + @override + String get extraAggressiveAvifCompression => + 'Bardzo agresywna kompresja AVIF'; + + @override + String failedToAddContact(String error) { + return 'Nie udało się dodać kontaktu: $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Nie udało się dodać $name: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Nie udało się wyczyścić kanałów: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Nie udało się wyczyścić kontaktów: $error'; + } + + @override + String get failedToConnectViaSerial => + 'Nie udało się połączyć przez port szeregowy'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Nie udało się wczytać podglądu obrazu: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Nie udało się wczytać trasy: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Nie udało się wysłać znacznika SAR: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Nie udało się ustawić trybu brzęczyka: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Nie udało się ustawić trybu GPS: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Nie udało się zsynchronizować czasu urządzenia: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Nie udało się wymazać danych urządzenia: $error'; + } + + @override + String get fastGpsTargetChannel => 'Docelowy kanał szybkiego GPS'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Szybkie aktualizacje, progi i uprawnienia'; + + @override + String get fetchingCatalog => 'Pobieranie katalogu...'; + + @override + String get fifteenMinutes => '15 minut'; + + @override + String get fifteenSeconds => '15 sekund'; + + @override + String get filterByDirection => 'Filtruj według kierunku'; + + @override + String get finish => 'Zakończ'; + + @override + String get fiveMinutes => '5 minut'; + + @override + String get fiveSeconds => '5 sekund'; + + @override + String get fix => 'Napraw'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'Zasady rozgłaszania GPS'; + + @override + String get gpsAndLocation => 'GPS i lokalizacja'; + + @override + String get gpsDiagnostics => 'Diagnostyka GPS'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Pomoc'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Ukryte'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Jak radio automatycznie dodaje odkryte węzły.'; + + @override + String get images => 'Obrazy'; + + @override + String get importProfile => 'Importuj profil'; + + @override + String get lastFix => 'Ostatni fix'; + + @override + String get latest => 'Najnowsze'; + + @override + String get latitude => 'Szerokość geograficzna'; + + @override + String get loadASavedRegion => 'Wczytaj zapisany region'; + + @override + String get loading => 'Ładowanie'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Udostępnianie lokalizacji, sprzęt i interwał aktualizacji.'; + + @override + String get locationTelemetry => 'Telemetria lokalizacji'; + + @override + String get lockDestination => 'Zablokuj cel'; + + @override + String get longitude => 'Długość geograficzna'; + + @override + String get mapStyle => 'Styl mapy'; + + @override + String get max => 'Maks.'; + + @override + String get maxZoom => 'Maks. przybliżenie'; + + @override + String get messaging => 'Wiadomości'; + + @override + String get metHistory => 'Historia MET'; + + @override + String get min => 'Min.'; + + @override + String get minZoom => 'Min. przybliżenie'; + + @override + String get moveDown => 'Przesuń w dół'; + + @override + String get moveUp => 'Przesuń w górę'; + + @override + String get multiAckMode => 'Tryb multi-ACK'; + + @override + String get multiDeviceWorkspaceManagement => + 'Zarządzanie przestrzenią roboczą wielu urządzeń'; + + @override + String get myLocation => 'Moja lokalizacja'; + + @override + String get nameAndTelemetryShared => + 'Nazwa i telemetria udostępniana innym urządzeniom.'; + + @override + String newVersionAvailable(String version) { + return 'Nowa wersja: $version'; + } + + @override + String get newer => 'Nowsze'; + + @override + String get noNeighboursFound => 'Nie znaleziono sąsiadów'; + + @override + String get noServersFound => 'Nie znaleziono serwerów'; + + @override + String get notSet => 'Nie ustawiono'; + + @override + String get notifications => 'Powiadomienia'; + + @override + String get offZeroSeconds => 'Wył. (0 s)'; + + @override + String get offlineMaps => 'Mapy offline'; + + @override + String get older => 'Starsze'; + + @override + String get oneByteMode0 => '1 bajt (tryb 0)'; + + @override + String get oneHour => '1 godzina'; + + @override + String get oneMinute => '1 minuta'; + + @override + String get openPacketLogs => 'Otwórz logi pakietów'; + + @override + String get packetTypeHelp => 'Pomoc typów pakietów'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Wklej link udostępniania lub ogłoszenie Hex'; + + @override + String get pathHash => 'Hash ścieżki'; + + @override + String get pathSize => 'Rozmiar ścieżki'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Wielokąt'; + + @override + String get rawResponseCopied => 'Skopiowano surową odpowiedź'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Ponów wysyłkę przez najbliższy przekaźnik po niepowodzeniu'; + + @override + String get resolveContact => 'Rozwiąż kontakt'; + + @override + String get routingRetriesAndDestinationLock => + 'Routing, ponawianie i blokada celu'; + + @override + String get samples => 'Próbki'; + + @override + String get satellites => 'Satelity'; + + @override + String get saving => 'Zapisywanie...'; + + @override + String get search => 'Szukaj'; + + @override + String get searchByNameOrToken => 'Szukaj po nazwie lub tokenie'; + + @override + String get searchDiscoveredNodes => 'Szukaj odkrytych węzłów'; + + @override + String get searchInCurrentFilter => 'Szukaj w bieżącym filtrze'; + + @override + String get searchLogs => 'Szukaj w logach...'; + + @override + String get searchSensors => 'Szukaj czujników'; + + @override + String get searchThisSection => 'Szukaj w tej sekcji'; + + @override + String get selectedRelays => 'Wybrane przekaźniki'; + + @override + String get sensor => 'Czujnik'; + + @override + String serialError(String error) { + return 'Błąd portu szeregowego: $error'; + } + + @override + String setPathForContact(String name) { + return 'Ustaw ścieżkę dla $name'; + } + + @override + String get shareLiveGps => 'Udostępnij GPS na żywo'; + + @override + String get shareMyTiles => 'Udostępnij moje kafelki'; + + @override + String get sharingLocationFromPhone => + 'Udostępnianie lokalizacji na tym kanale z telefonu.'; + + @override + String get sharingLocationFromRadio => + 'Udostępnianie lokalizacji na tym kanale z radia.'; + + @override + String signalDbm(String rssi) { + return 'Sygnał $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => 'Rozmiar, kompresja i podgląd'; + + @override + String get simpleMode => 'Tryb prosty'; + + @override + String get simpleModeDescription => + 'Tylko czat i mapa z ulubionymi kontaktami'; + + @override + String get simpleModeRequiresLockedDestination => + 'Aby użyć trybu prostego, najpierw włącz „Zablokuj cel” z kanałem'; + + @override + String get noFavouriteContacts => 'Brak ulubionych kontaktów'; + + @override + String get skip => 'Pomiń'; + + @override + String get smallerFileSize => 'Mniejszy rozmiar pliku'; + + @override + String get sort => 'Sortuj'; + + @override + String get stoppedSharingLocation => + 'Zatrzymano udostępnianie lokalizacji na tym kanale.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Wycisz powiadomienia, gdy aplikacja jest na pierwszym planie'; + + @override + String get tabsAndNavigation => 'Zakładki i nawigacja'; + + @override + String get templatesAndTutorials => 'Szablony i samouczki'; + + @override + String get tenMinutes => '10 minut'; + + @override + String get tenSeconds => '10 sekund'; + + @override + String get testSendUpdate => 'Testowe wysłanie aktualizacji'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Motyw, język i preferencje wyświetlania'; + + @override + String get thirtyMinutes => '30 minut'; + + @override + String get thirtySeconds => '30 sekund'; + + @override + String get threeBytesMode2 => '3 bajty (tryb 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Statystyki ruchu, historia wiadomości i narzędzia deweloperskie'; + + @override + String get twoBytesMode1 => '2 bajty (tryb 1)'; + + @override + String get twoMinutes => '2 minuty'; + + @override + String get undo => 'Cofnij'; + + @override + String get updated => 'Zaktualizowano'; + + @override + String get useContactFlags => 'Używaj flag kontaktów'; + + @override + String get useSavedCoordinates => 'Użyj zapisanych współrzędnych'; + + @override + String get view => 'Widok'; + + @override + String get viewNeighbours => 'Pokaż sąsiadów'; + + @override + String get viewPublicStats => 'Pokaż publiczne statystyki'; + + @override + String get setRegionScope => 'Ustaw zakres regionu'; + + @override + String get regionScope => 'Zakres regionu'; + + @override + String get regionScopeNone => 'Brak (globalny)'; + + @override + String get clearRegionScope => 'Wyczyść zakres'; + + @override + String get regionScopeWarning => + 'Tylko przekaźniki zezwalające na ten region będą przekazywać.'; + + @override + String get discoverRegions => 'Odkryj z przekaźników'; + + @override + String get discoveringRegions => 'Odkrywanie regionów...'; + + @override + String get enterRegionName => 'Nazwa regionu (np. auckland)'; + + @override + String get noRegionsFound => 'Nie znaleziono regionów na tym przekaźniku.'; + + @override + String regionScopeSet(String name) { + return 'Zakres regionu ustawiony na $name'; + } + + @override + String get regionScopeCleared => 'Zakres regionu wyczyszczony'; + + @override + String get selectDestinationFirst => + 'Najpierw wybierz kanał, kontakt lub pokój'; + + @override + String get recipientNoLongerAvailable => 'Odbiorca nie jest już dostępny'; + + @override + String failedToSendMessage(String error) { + return 'Nie udało się wysłać: $error'; + } + + @override + String get tictactoeDirectOnly => + 'Kółko i krzyżyk działa tylko w wiadomościach bezpośrednich. Najpierw wybierz kontakt.'; + + @override + String get deviceKeyUnavailable => 'Klucz urządzenia jest niedostępny'; + + @override + String get imageCompressionFailed => 'Kompresja obrazu nie powiodła się'; + + @override + String get imageFragmentationFailed => 'Fragmentacja obrazu nie powiodła się'; + + @override + String get failedToAnnounceImage => 'Nie udało się ogłosić obrazu'; + + @override + String get noRecipientSelected => 'Nie wybrano odbiorcy'; + + @override + String get imageSendFailed => 'Wysyłanie obrazu nie powiodło się'; + + @override + String get microphonePermissionRequiredForVoice => + 'Wiadomości głosowe wymagają uprawnienia do mikrofonu'; + + @override + String get recordingDiscarded => 'Nagranie odrzucone'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Za chwilę wyślesz $mediaType na kanał publiczny. Nie jest to zalecane, ponieważ może to odebrać każdy w sieci mesh. Wybierz kanał prywatny lub oznaczony, chyba że właśnie tego chcesz.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Wybierz prywatny kanał, aby udostępnić swoją lokalizację.'; + + @override + String get stopSharingMyLocation => 'Przestań udostępniać moją lokalizację'; + + @override + String get shareMyLocation => 'Udostępnij moją lokalizację'; + + @override + String get stopRecording => 'Zatrzymaj nagrywanie'; + + @override + String get recordVoice => 'Nagraj wiadomość głosową'; + + @override + String get moreActions => 'Więcej działań'; + + @override + String get noMessagesInFilter => 'Brak wiadomości w tym filtrze'; + + @override + String get noMatchesInFilter => 'Brak dopasowań w tym filtrze'; + + @override + String syncFailed(String error) { + return 'Synchronizacja nie powiodła się: $error'; + } + + @override + String get invalidLink => 'Nieprawidłowy link'; + + @override + String get cannotOpenLink => 'Nie można otworzyć linku'; + + @override + String get failedToOpenLink => 'Nie udało się otworzyć linku'; + + @override + String get failedToResendMessage => + 'Nie udało się ponownie wysłać wiadomości'; + + @override + String retryFailed(String error) { + return 'Ponowna próba nie powiodła się: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Usuń rysunek i wiadomość'; + + @override + String get lastEchoRelay => 'Ostatnie echo – przekaźnik'; + + @override + String get lastEchoPath => 'Ostatnie echo – ścieżka'; + + @override + String get lastEchoBytesReport => 'Ostatnie echo – raport bajtów'; + + @override + String get noGpsCoordinatesAvailable => 'Brak dostępnych współrzędnych GPS'; + + @override + String get notASarMarker => 'To nie jest znacznik SAR'; + + @override + String failedToSaveTemplate(String error) { + return 'Nie udało się zapisać szablonu: $error'; + } + + @override + String get drawingNotFound => 'Nie znaleziono rysunku'; + + @override + String get unknownDrawingType => 'Nieznany typ rysunku'; + + @override + String get customMapMarker => 'Niestandardowy znacznik mapy'; + + @override + String customMapPointLabel(String point) { + return 'Punkt: $point'; + } + + @override + String mapIdLabel(String id) { + return 'ID mapy: $id'; + } + + @override + String get cannotFetchVoice => 'Nie można pobrać wiadomości głosowej'; + + @override + String get senderContactUnknown => + 'Kontakt nadawcy jest nieznany. Najpierw zsynchronizuj kontakty.'; + + @override + String get senderRouteUnknown => + 'Trasa nadawcy jest nieznana. Najpierw zsynchronizuj kontakty/ścieżkę.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Wiadomość jest zbyt daleko ($hops skoków, maks. $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'Trasa nadawcy nie odpowiedziała na sprawdzenie ścieżki. Zsynchronizuj kontakty/ścieżkę i spróbuj ponownie.'; + + @override + String get senderRouteNoRawResponse => + 'Trasa nadawcy nie odpowiedziała na surowej ścieżce transportowej.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Pobieranie głosu przez $hops skoków może chwilę potrwać.'; + } + + @override + String get voiceReceiveCanceled => 'Odbieranie głosu anulowane'; + + @override + String get fetchingMissingVoiceFragments => + 'Pobieranie brakujących fragmentów głosu'; + + @override + String get receivingVoice => 'Odbieranie głosu'; + + @override + String get cannotFetchImage => 'Nie można pobrać obrazu'; + + @override + String imageFetchOverHops(String hops) { + return 'Pobieranie obrazu przez $hops skoków może chwilę potrwać.'; + } + + @override + String get imageAlreadyBeingReceived => 'Obraz jest już odbierany'; + + @override + String get loadImage => 'Wczytaj obraz'; + + @override + String get imageFetchFailedToSendRequest => + 'Nie udało się wysłać żądania pobrania obrazu'; + + @override + String get imageUnavailable => 'Obraz chwilowo niedostępny'; + + @override + String get imageFetchTimedOut => 'Upłynął limit czasu pobierania obrazu'; + + @override + String get imageReceiveCanceled => 'Odbieranie obrazu anulowane'; + + @override + String get closeImagePreview => 'Zamknij podgląd obrazu'; + + @override + String get recordingReleaseToSend => 'Nagrywanie... puść, aby wysłać'; + + @override + String get sendingVoice => 'Wysyłanie głosu...'; + + @override + String get sendLongPressToRecordVoice => + 'Wyślij (przytrzymaj, aby nagrać głos)'; + + @override + String get discardRecording => 'Odrzuć nagranie'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count skoku', + many: '$count skoków', + few: '$count skoki', + one: '$count skok', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Kanały rozgłoszeniowe dla pobliskiego ruchu mesh'; + + @override + String get roomsSectionDescription => + 'Wspólne przestrzenie do bieżącej koordynacji zespołu'; + + @override + String get contactsSectionDescription => + 'Osoby i urządzenia w bezpośrednim zasięgu'; + + @override + String get noRecentChatters => 'Brak ostatnich rozmówców'; + + @override + String get public => 'Publiczny'; + + @override + String get failedToSendTicTacToeMove => + 'Nie udało się wysłać ruchu w kółko i krzyżyk'; + + @override + String get youWon => 'Wygrałeś'; + + @override + String get opponentWon => 'Wygrał przeciwnik'; + + @override + String get gameDraw => 'Remis'; + + @override + String get yourTurn => 'Twoja kolej'; + + @override + String get opponentTurn => 'Kolej przeciwnika'; + + @override + String get pleaseEnterValidCoordinates => 'Wprowadź prawidłowe współrzędne'; + + @override + String get locationNotAvailable => 'Lokalizacja niedostępna'; + + @override + String get customCaveMapPoint => 'Niestandardowy punkt mapy jaskini'; + + @override + String get mapPoint => 'Punkt mapy'; + + @override + String get fetchingMissingImageFragments => + 'Pobieranie brakujących fragmentów…'; + + @override + String get loadingImage => 'Wczytywanie…'; + + @override + String get receivingImage => 'Odbieranie…'; + + @override + String get tapToLoad => 'Dotknij, aby wczytać'; + + @override + String get connectDevice => 'Połącz urządzenie'; + + @override + String get chooseTransportSubtitle => + 'Wybierz transport: Bluetooth, WiFi lub port szeregowy'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Sieć'; + + @override + String get serial => 'Port szeregowy'; + + @override + String get scan => 'Skanuj'; + + @override + String get pressScanToSearchForDevices => + 'Naciśnij Skanuj, aby wyszukać pobliskie urządzenia'; + + @override + String failedToConnectToDevice(String name) { + return 'Nie udało się połączyć z $name'; + } + + @override + String get unknownDevice => 'Nieznane urządzenie'; + + @override + String get showingCachedResultsTapRefresh => + 'Wyświetlane są wyniki z pamięci podręcznej. Dotknij odświeżania, aby zeskanować ponownie.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Skanowanie sieci lokalnej w poszukiwaniu urządzeń MeshCore WiFi na porcie $port'; + } + + @override + String get cancelScanAndAddServer => 'Anuluj skanowanie i dodaj serwer'; + + @override + String get addServer => 'Dodaj serwer'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Skanowanie... $scanned/$total adresów IP'; + } + + @override + String get cancelAndEnterManually => 'Anuluj i wprowadź ręcznie'; + + @override + String get noRecentOrDiscoveredServers => + 'Brak ostatnich ani wykrytych serwerów'; + + @override + String get recentlyUsed => 'Ostatnio używane'; + + @override + String get connecting => 'Łączenie...'; + + @override + String get discoveredOnThisNetwork => 'Wykryte w tej sieci'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'Serwer $host:$port nie jest już dostępny. Zeskanuj ponownie, aby znaleźć aktywne serwery.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Nie udało się połączyć z $host:$port'; + } + + @override + String get enterValidIpAddress => 'Wprowadź prawidłowy adres IP'; + + @override + String get enterValidTcpPort => 'Wprowadź prawidłowy port TCP'; + + @override + String get ipAddress => 'Adres IP'; + + @override + String get tcpPort => 'Port TCP'; + + @override + String get customServerPort => 'Niestandardowy port serwera'; + + @override + String get chat => 'Czat'; + + @override + String get restoringPreviousLink => 'Przywracanie poprzedniego połączenia'; + + @override + String get noDeviceConnected => 'Brak połączonego urządzenia'; + + @override + String get sendAdvert => 'Wyślij mój kontakt'; + + @override + String get advertMode => 'Wyślij mój kontakt'; + + @override + String get advertModeSubtitle => + 'Wybierz, jak daleko mają dotrzeć Twoje dane kontaktowe.'; + + @override + String get floodAdvertSent => 'Kontakt wysłano do całej sieci mesh'; + + @override + String get directAdvertSent => 'Kontakt wysłano do urządzeń w pobliżu'; +} diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart new file mode 100644 index 0000000..8c4db0b --- /dev/null +++ b/lib/l10n/app_localizations_pt.dart @@ -0,0 +1,3459 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Portuguese (`pt`). +class AppLocalizationsPt extends AppLocalizations { + AppLocalizationsPt([String locale = 'pt']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Mensagens'; + + @override + String get contacts => 'Contatos'; + + @override + String get map => 'Mapa'; + + @override + String get settings => 'Configurações'; + + @override + String get connect => 'Conectar'; + + @override + String get disconnect => 'Desconectar'; + + @override + String get noDevicesFound => 'Nenhum dispositivo encontrado'; + + @override + String get scanAgain => 'Escanear novamente'; + + @override + String get deviceNotConnected => 'Dispositivo não conectado'; + + @override + String get locationPermissionDenied => 'Permissão de localização negada'; + + @override + String get locationPermissionPermanentlyDenied => + 'Permissão de localização negada permanentemente. Ative-a em Configurações.'; + + @override + String get locationPermissionRequired => + 'A permissão de localização é necessária para rastreamento GPS e coordenação da equipe. Você pode ativá-la depois em Configurações.'; + + @override + String get locationServicesDisabled => + 'Os serviços de localização estão desativados. Ative-os em Configurações.'; + + @override + String get failedToGetGpsLocation => 'Falha ao obter localização GPS'; + + @override + String failedToAdvertise(String error) { + return 'Falha ao anunciar: $error'; + } + + @override + String get cancelReconnection => 'Cancelar reconexão'; + + @override + String get general => 'Geral'; + + @override + String get theme => 'Tema'; + + @override + String get chooseTheme => 'Escolher tema'; + + @override + String get light => 'Claro'; + + @override + String get dark => 'Escuro'; + + @override + String get blueLightTheme => 'Tema azul claro'; + + @override + String get blueDarkTheme => 'Tema azul escuro'; + + @override + String get sarRed => 'SAR Vermelho'; + + @override + String get alertEmergencyMode => 'Modo de alerta/emergência'; + + @override + String get sarGreen => 'SAR Verde'; + + @override + String get safeAllClearMode => 'Modo seguro/tudo limpo'; + + @override + String get autoSystem => 'Automático (Sistema)'; + + @override + String get followSystemTheme => 'Seguir tema do sistema'; + + @override + String get showRxTxIndicators => 'Mostrar indicadores RX/TX'; + + @override + String get disableMap => 'Desativar mapa'; + + @override + String get language => 'Idioma'; + + @override + String get chooseLanguage => 'Escolher idioma'; + + @override + String get save => 'Salvar'; + + @override + String get cancel => 'Cancelar'; + + @override + String get close => 'Fechar'; + + @override + String get about => 'Sobre'; + + @override + String get appVersion => 'Versão do app'; + + @override + String get aboutMeshCoreSar => 'Sobre o MeshCore SAR'; + + @override + String get aboutDescription => + 'Um aplicativo de Busca e Resgate projetado para equipes de resposta a emergências. Os recursos incluem:\n\n• Rede mesh BLE para comunicação entre dispositivos\n• Mapas offline com múltiplas opções de camadas\n• Rastreamento em tempo real dos membros da equipe\n• Marcadores táticos SAR (pessoa encontrada, incêndio, área de apoio)\n• Gerenciamento de contatos e mensagens\n• Rastreamento GPS com rumo da bússola\n• Cache de blocos de mapa para uso offline'; + + @override + String get technologiesUsed => 'Tecnologias utilizadas:'; + + @override + String get technologiesList => + '• Flutter para desenvolvimento multiplataforma\n• BLE (Bluetooth Low Energy) para rede mesh\n• OpenStreetMap para mapas\n• Provider para gerenciamento de estado\n• SharedPreferences para armazenamento local'; + + @override + String get moreInfo => 'Mais informações'; + + @override + String get packageName => 'Nome do pacote'; + + @override + String get sampleData => 'Dados de exemplo'; + + @override + String get sampleDataDescription => + 'Carregar ou limpar contatos, mensagens de canal e marcadores SAR de exemplo para testes'; + + @override + String get loadSampleData => 'Carregar dados de exemplo'; + + @override + String get clearAllData => 'Limpar todos os dados'; + + @override + String get clearAllDataConfirmTitle => 'Limpar todos os dados'; + + @override + String get clearAllDataConfirmMessage => + 'Isso limpará todos os contatos e marcadores SAR. Tem certeza?'; + + @override + String get clear => 'Limpar'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Carregados $teamCount membros da equipe, $channelCount canais, $sarCount marcadores SAR, $messageCount mensagens'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Falha ao carregar dados de exemplo: $error'; + } + + @override + String get allDataCleared => 'Todos os dados foram limpos'; + + @override + String get failedToStartBackgroundTracking => + 'Falha ao iniciar rastreamento em segundo plano. Verifique permissões e conexão BLE.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Transmissão de localização: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'O PIN padrão para dispositivos sem tela é 123456. Problemas ao parear? Esqueça o dispositivo Bluetooth nas configurações do sistema.'; + + @override + String get noMessagesYet => 'Ainda não há mensagens'; + + @override + String get pullDownToSync => 'Puxe para baixo para sincronizar mensagens'; + + @override + String get deleteContact => 'Excluir contato'; + + @override + String get delete => 'Excluir'; + + @override + String get viewOnMap => 'Ver no mapa'; + + @override + String get refresh => 'Atualizar'; + + @override + String get resetPath => 'Redefinir rota'; + + @override + String copiedToClipboard(String label) { + return '$label copiado para a área de transferência'; + } + + @override + String get pleaseEnterPassword => 'Digite uma senha'; + + @override + String failedToSyncContacts(String error) { + return 'Falha ao sincronizar contatos: $error'; + } + + @override + String get loggedInSuccessfully => + 'Login realizado com sucesso! Aguardando mensagens da sala...'; + + @override + String get loginFailed => 'Falha no login - senha incorreta'; + + @override + String loggingIn(String roomName) { + return 'Entrando em $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Falha ao enviar login: $error'; + } + + @override + String get lowLocationAccuracy => 'Baixa precisão de localização'; + + @override + String get continue_ => 'Continuar'; + + @override + String get sendSarMarker => 'Enviar marcador SAR'; + + @override + String get deleteDrawing => 'Excluir desenho'; + + @override + String get drawingTools => 'Ferramentas de desenho'; + + @override + String get drawLine => 'Desenhar linha'; + + @override + String get drawLineDesc => 'Desenhar uma linha livre no mapa'; + + @override + String get drawRectangle => 'Desenhar retângulo'; + + @override + String get drawRectangleDesc => 'Desenhar uma área retangular no mapa'; + + @override + String get measureDistance => 'Medir distância'; + + @override + String get measureDistanceDesc => + 'Pressione longamente dois pontos para medir'; + + @override + String get clearMeasurement => 'Limpar medição'; + + @override + String distanceLabel(String distance) { + return 'Distância: $distance'; + } + + @override + String get longPressForSecondPoint => + 'Pressione longamente para o segundo ponto'; + + @override + String get longPressToStartMeasurement => + 'Pressione longamente para definir o primeiro ponto'; + + @override + String get longPressToStartNewMeasurement => + 'Pressione e segure para iniciar uma nova medição'; + + @override + String get shareDrawings => 'Compartilhar desenhos'; + + @override + String get clearAllDrawings => 'Limpar todos os desenhos'; + + @override + String get completeLine => 'Concluir linha'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Transmitir $count desenho$plural para a equipe'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Remover todos os $count desenho$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Excluir todos os $count desenho$plural do mapa?'; + } + + @override + String get drawing => 'Desenho'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Compartilhar $count desenho$plural'; + } + + @override + String get showReceivedDrawings => 'Mostrar desenhos recebidos'; + + @override + String get showingAllDrawings => 'Mostrando todos os desenhos'; + + @override + String get showingOnlyYourDrawings => 'Mostrando apenas seus desenhos'; + + @override + String get showSarMarkers => 'Mostrar marcadores SAR'; + + @override + String get showingSarMarkers => 'Mostrando marcadores SAR'; + + @override + String get hidingSarMarkers => 'Ocultando marcadores SAR'; + + @override + String get clearAll => 'Limpar tudo'; + + @override + String get publicChannel => 'Canal público'; + + @override + String get broadcastToAll => 'Transmitir para todos os nós próximos'; + + @override + String get storedPermanently => 'Armazenado permanentemente na sala'; + + @override + String get notConnectedToDevice => 'Não conectado ao dispositivo'; + + @override + String get typeYourMessage => 'Digite sua mensagem...'; + + @override + String get quickLocationMarker => 'Marcador rápido de localização'; + + @override + String get markerType => 'Tipo de marcador'; + + @override + String get sendTo => 'Enviar para'; + + @override + String get noDestinationsAvailable => 'Nenhum destino disponível.'; + + @override + String get selectDestination => 'Selecione o destino...'; + + @override + String get ephemeralBroadcastInfo => + 'Efêmero: transmitido apenas pelo ar. Não é armazenado; os nós precisam estar online.'; + + @override + String get persistentRoomInfo => + 'Persistente: armazenado de forma imutável na sala. Sincronizado automaticamente e preservado offline.'; + + @override + String get location => 'Localização'; + + @override + String get fromMap => 'Do mapa'; + + @override + String get gettingLocation => 'Obtendo localização...'; + + @override + String get locationError => 'Erro de localização'; + + @override + String get retry => 'Tentar novamente'; + + @override + String get refreshLocation => 'Atualizar localização'; + + @override + String accuracyMeters(int accuracy) { + return 'Precisão: ±${accuracy}m'; + } + + @override + String get notesOptional => 'Notas (opcional)'; + + @override + String get addAdditionalInformation => 'Adicionar informações extras...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'A precisão da localização é ±${accuracy}m. Isso pode não ser preciso o suficiente para operações SAR.\n\nDeseja continuar mesmo assim?'; + } + + @override + String get loginToRoom => 'Entrar na sala'; + + @override + String get enterPasswordInfo => + 'Digite a senha para acessar esta sala. A senha será salva para uso futuro.'; + + @override + String get password => 'Senha'; + + @override + String get enterRoomPassword => 'Digite a senha da sala'; + + @override + String get loggingInDots => 'Entrando...'; + + @override + String get login => 'Entrar'; + + @override + String failedToAddRoom(String error) { + return 'Falha ao adicionar a sala ao dispositivo: $error\n\nA sala talvez ainda não tenha sido anunciada.\nTente esperar a sala começar a transmitir.'; + } + + @override + String get direct => 'Direto'; + + @override + String get flood => 'Inundação'; + + @override + String get autoSend => 'Envio automático'; + + @override + String get autoSendDescription => 'Enviar pela rota atual.'; + + @override + String get sendDirect => 'Envio direto'; + + @override + String get sendDirectDescription => 'Enviar diretamente a este contacto.'; + + @override + String get sendFlood => 'Envio por inundação'; + + @override + String get sendFloodDescription => 'Enviar por todos os repetidores.'; + + @override + String get loggedIn => 'Conectado'; + + @override + String get noGpsData => 'Sem dados GPS'; + + @override + String get distance => 'Distância'; + + @override + String directPingTimeout(String name) { + return 'Tempo limite do ping direto - tentando novamente $name com flooding...'; + } + + @override + String pingFailed(String name) { + return 'Falha no ping para $name - nenhuma resposta recebida'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Tem certeza de que deseja excluir \"$name\"?\n\nIsso removerá o contato tanto do aplicativo quanto do dispositivo de rádio associado.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Falha ao remover contato: $error'; + } + + @override + String get type => 'Tipo'; + + @override + String get publicKey => 'Chave pública'; + + @override + String get lastSeen => 'Visto por último'; + + @override + String get yes => 'Sim'; + + @override + String get no => 'Não'; + + @override + String get permissions => 'Permissões'; + + @override + String get telemetry => 'Telemetria'; + + @override + String get voltage => 'Voltagem'; + + @override + String get battery => 'Bateria'; + + @override + String get temperature => 'Temperatura'; + + @override + String get humidity => 'Umidade'; + + @override + String get pressure => 'Pressão'; + + @override + String get gpsTelemetry => 'GPS (telemetria)'; + + @override + String get reLoginToRoom => 'Entrar novamente na sala'; + + @override + String get heading => 'Direção'; + + @override + String get elevation => 'Elevação'; + + @override + String get accuracy => 'Precisão'; + + @override + String get bearing => 'Rumo'; + + @override + String get direction => 'Direção'; + + @override + String get filterMarkers => 'Filtrar marcadores'; + + @override + String get filterMarkersTooltip => 'Filtrar marcadores'; + + @override + String get contactsFilter => 'Contatos'; + + @override + String get repeatersFilter => 'Repetidores'; + + @override + String get sarMarkers => 'Marcadores SAR'; + + @override + String get foundPerson => 'Pessoa encontrada'; + + @override + String get fire => 'Incêndio'; + + @override + String get stagingArea => 'Área de apoio'; + + @override + String get showAll => 'Mostrar tudo'; + + @override + String get locationUnavailable => 'Localização indisponível'; + + @override + String get ahead => 'à frente'; + + @override + String degreesRight(int degrees) { + return '$degrees° à direita'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° à esquerda'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Lat.: $latitude Lon.: $longitude'; + } + + @override + String get noContactsYet => 'Ainda não há contatos'; + + @override + String get connectToDeviceToLoadContacts => + 'Conecte-se a um dispositivo para carregar contatos'; + + @override + String get teamMembers => 'Membros da equipe'; + + @override + String get repeaters => 'Repetidores'; + + @override + String get rooms => 'Salas'; + + @override + String get channels => 'Canais'; + + @override + String get selectMapLayer => 'Selecionar camada do mapa'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'Satélite ESRI'; + + @override + String get googleHybrid => 'Google Híbrido'; + + @override + String get googleRoadmap => 'Google Mapa'; + + @override + String get googleTerrain => 'Google Terreno'; + + @override + String get dragToPosition => 'Arraste para a posição'; + + @override + String get createSarMarker => 'Criar marcador SAR'; + + @override + String get compass => 'Bússola'; + + @override + String get navigationAndContacts => 'Navegação e contatos'; + + @override + String get sarAlert => 'ALERTA SAR'; + + @override + String get textCopiedToClipboard => + 'Texto copiado para a área de transferência'; + + @override + String get cannotReplySenderMissing => + 'Não é possível responder: faltam informações do remetente'; + + @override + String get cannotReplyContactNotFound => + 'Não é possível responder: contato não encontrado'; + + @override + String get copyText => 'Copiar texto'; + + @override + String get saveAsTemplate => 'Salvar como modelo'; + + @override + String get templateSaved => 'Modelo salvo com sucesso'; + + @override + String get templateAlreadyExists => 'Já existe um modelo com este emoji'; + + @override + String get deleteMessage => 'Excluir mensagem'; + + @override + String get deleteMessageConfirmation => + 'Tem certeza de que deseja excluir esta mensagem?'; + + @override + String get shareLocation => 'Compartilhar localização'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nCoordenadas: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'Localização SAR'; + + @override + String get justNow => 'Agora mesmo'; + + @override + String minutesAgo(int minutes) { + return 'há $minutes min'; + } + + @override + String hoursAgo(int hours) { + return 'há $hours h'; + } + + @override + String daysAgo(int days) { + return 'há $days d'; + } + + @override + String secondsAgo(int seconds) { + return 'há $seconds s'; + } + + @override + String get sending => 'Enviando...'; + + @override + String get sent => 'Enviado'; + + @override + String get delivered => 'Entregue'; + + @override + String deliveredWithTime(int time) { + return 'Entregue (${time}ms)'; + } + + @override + String get failed => 'Falhou'; + + @override + String get broadcast => 'Transmitir'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Entregue a $delivered/$total contatos'; + } + + @override + String get allDelivered => 'Todos entregues'; + + @override + String get recipientDetails => 'Detalhes do destinatário'; + + @override + String get pending => 'Pendente'; + + @override + String get sarMarkerFoundPerson => 'Pessoa encontrada'; + + @override + String get sarMarkerFire => 'Local do incêndio'; + + @override + String get sarMarkerStagingArea => 'Área de apoio'; + + @override + String get sarMarkerObject => 'Objeto encontrado'; + + @override + String get from => 'De'; + + @override + String get coordinates => 'Coordenadas'; + + @override + String get tapToViewOnMap => 'Toque para ver no mapa'; + + @override + String get radioSettings => 'Configurações do rádio'; + + @override + String get frequencyMHz => 'Frequência (MHz)'; + + @override + String get bandwidth => 'Largura de banda'; + + @override + String get spreadingFactor => 'Fator de espalhamento'; + + @override + String get codingRate => 'Taxa de codificação'; + + @override + String get txPowerDbm => 'Potência TX (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Máx: $power dBm'; + } + + @override + String get you => 'Você'; + + @override + String exportFailed(String error) { + return 'Falha na exportação: $error'; + } + + @override + String importFailed(String error) { + return 'Falha na importação: $error'; + } + + @override + String get unknown => 'Desconhecido'; + + @override + String get onlineLayers => 'Camadas online'; + + @override + String get locationTrail => 'Trilha de localização'; + + @override + String get showTrailOnMap => 'Mostrar trilha no mapa'; + + @override + String get trailVisible => 'A trilha está visível no mapa'; + + @override + String get trailHiddenRecording => 'A trilha está oculta (ainda gravando)'; + + @override + String get duration => 'Duração'; + + @override + String get points => 'Pontos'; + + @override + String get clearTrail => 'Limpar trilha'; + + @override + String get clearTrailQuestion => 'Limpar trilha?'; + + @override + String get clearTrailConfirmation => + 'Tem certeza de que deseja limpar a trilha de localização atual?'; + + @override + String get noTrailRecorded => 'Nenhuma trilha gravada ainda'; + + @override + String get startTrackingToRecord => + 'Inicie o rastreamento de localização para gravar sua trilha'; + + @override + String get trailControls => 'Controles da trilha'; + + @override + String get model => 'Modelo'; + + @override + String get version => 'Versão'; + + @override + String get maxContacts => 'Máx. de contatos'; + + @override + String get maxChannels => 'Máx. de canais'; + + @override + String get publicInfo => 'Informações públicas'; + + @override + String get useCurrentLocation => 'Usar localização atual'; + + @override + String get noneUnknown => 'Nenhum/Desconhecido'; + + @override + String get chatNode => 'Nó de chat'; + + @override + String get repeater => 'Repetidor'; + + @override + String get roomChannel => 'Sala/Canal'; + + @override + String typeNumber(int number) { + return 'Tipo $number'; + } + + @override + String failedToSave(String error) { + return 'Falha ao salvar: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Falha ao obter localização: $error'; + } + + @override + String get sarTemplates => 'Modelos SAR'; + + @override + String get addTemplate => 'Adicionar modelo'; + + @override + String get editTemplate => 'Editar modelo'; + + @override + String get deleteTemplate => 'Excluir modelo'; + + @override + String get templateName => 'Nome do modelo'; + + @override + String get templateNameHint => 'ex.: Pessoa encontrada'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji é obrigatório'; + + @override + String get nameRequired => 'Nome é obrigatório'; + + @override + String get templateDescription => 'Descrição (opcional)'; + + @override + String get templateDescriptionHint => 'Adicionar contexto extra...'; + + @override + String get templateColor => 'Cor'; + + @override + String get previewFormat => 'Pré-visualização (formato de mensagem SAR)'; + + @override + String get importFromClipboard => 'Importar'; + + @override + String get exportToClipboard => 'Exportar'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Excluir o modelo “$name”?'; + } + + @override + String get templateAdded => 'Modelo adicionado'; + + @override + String get templateUpdated => 'Modelo atualizado'; + + @override + String get templateDeleted => 'Modelo excluído'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count modelos importados', + one: '1 modelo importado', + zero: 'Nenhum modelo importado', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count modelos exportados para a área de transferência', + one: '1 modelo exportado para a área de transferência', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Restaurar padrão'; + + @override + String get resetToDefaultsConfirmation => + 'Isso excluirá todos os modelos personalizados e restaurará os 4 modelos padrão. Continuar?'; + + @override + String get reset => 'Redefinir'; + + @override + String get resetComplete => 'Modelos redefinidos para o padrão'; + + @override + String get noTemplates => 'Nenhum modelo disponível'; + + @override + String get tapAddToCreate => 'Toque em + para criar seu primeiro modelo'; + + @override + String get ok => 'OK'; + + @override + String get locationPermission => 'Permissão de localização'; + + @override + String get checking => 'Verificando...'; + + @override + String get locationPermissionGrantedAlways => 'Concedida (Sempre)'; + + @override + String get locationPermissionGrantedWhileInUse => 'Concedida (Durante o uso)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Negada - toque para solicitar'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Negada permanentemente - abrir configurações'; + + @override + String get locationPermissionDialogContent => + 'A permissão de localização foi negada permanentemente. Ative-a nas configurações do dispositivo.'; + + @override + String get openSettings => 'Abrir configurações'; + + @override + String get locationPermissionGranted => 'Permissão de localização concedida!'; + + @override + String get locationPermissionRequiredForGps => + 'A permissão de localização é necessária para rastreamento GPS e compartilhamento de localização.'; + + @override + String get locationPermissionAlreadyGranted => + 'A permissão de localização já foi concedida.'; + + @override + String get sarNavyBlue => 'SAR Azul Marinho'; + + @override + String get sarNavyBlueDescription => 'Modo profissional/operações'; + + @override + String get selectRecipient => 'Selecionar destinatário'; + + @override + String get broadcastToAllNearby => 'Transmitir para todos os próximos'; + + @override + String get searchRecipients => 'Pesquisar destinatários...'; + + @override + String get noContactsFound => 'Nenhum contato encontrado'; + + @override + String get noRoomsFound => 'Nenhuma sala encontrada'; + + @override + String get noRecipientsAvailable => 'Nenhum destinatário disponível'; + + @override + String get noChannelsFound => 'Nenhum canal encontrado'; + + @override + String get newMessage => 'Nova mensagem'; + + @override + String get channel => 'Canal'; + + @override + String get samplePoliceLead => 'Chefe da polícia'; + + @override + String get sampleDroneOperator => 'Operador de drone'; + + @override + String get sampleFirefighterAlpha => 'Bombeiro'; + + @override + String get sampleMedicCharlie => 'Paramédico'; + + @override + String get sampleCommandDelta => 'Comando'; + + @override + String get sampleFireEngine => 'Caminhão de bombeiros'; + + @override + String get sampleAirSupport => 'Apoio aéreo'; + + @override + String get sampleBaseCoordinator => 'Coordenador da base'; + + @override + String get channelEmergency => 'Emergência'; + + @override + String get channelCoordination => 'Coordenação'; + + @override + String get channelUpdates => 'Atualizações'; + + @override + String get sampleTeamMember => 'Membro de equipe de exemplo'; + + @override + String get sampleScout => 'Batedor de exemplo'; + + @override + String get sampleBase => 'Base de exemplo'; + + @override + String get sampleSearcher => 'Buscador de exemplo'; + + @override + String get sampleObjectBackpack => ' Mochila encontrada - cor azul'; + + @override + String get sampleObjectVehicle => + ' Veículo abandonado - verificar proprietário'; + + @override + String get sampleObjectCamping => ' Equipamento de camping encontrado'; + + @override + String get sampleObjectTrailMarker => + ' Marco de trilha encontrado fora da rota'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Todas as equipes, façam check-in'; + + @override + String get sampleMsgWeatherUpdate => + 'Atualização do tempo: céu limpo, temp. 18°C'; + + @override + String get sampleMsgBaseCamp => 'Base estabelecida na área de apoio'; + + @override + String get sampleMsgTeamAlpha => 'Equipe deslocando-se para o setor 2'; + + @override + String get sampleMsgRadioCheck => + 'Teste de rádio - todas as estações respondam'; + + @override + String get sampleMsgWaterSupply => + 'Abastecimento de água disponível no ponto de controle 3'; + + @override + String get sampleMsgTeamBravo => 'Equipe informa: setor 1 livre'; + + @override + String get sampleMsgEtaRallyPoint => + 'ETA para o ponto de reunião: 15 minutos'; + + @override + String get sampleMsgSupplyDrop => + 'Entrega de suprimentos confirmada para 14:00'; + + @override + String get sampleMsgDroneSurvey => + 'Levantamento com drone concluído - nada encontrado'; + + @override + String get sampleMsgTeamCharlie => 'Equipe solicitando reforço'; + + @override + String get sampleMsgRadioDiscipline => + 'Todas as unidades: mantenham disciplina de rádio'; + + @override + String get sampleMsgUrgentMedical => + 'URGENTE: assistência médica necessária no setor 4'; + + @override + String get sampleMsgAdultMale => ' Homem adulto, consciente'; + + @override + String get sampleMsgFireSpotted => 'Incêndio avistado - coordenadas a seguir'; + + @override + String get sampleMsgSpreadingRapidly => ' Se espalhando rapidamente!'; + + @override + String get sampleMsgPriorityHelicopter => + 'PRIORIDADE: necessidade de apoio de helicóptero'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Equipe médica a caminho da sua localização'; + + @override + String get sampleMsgEvacHelicopter => + 'ETA do helicóptero de evacuação: 10 minutos'; + + @override + String get sampleMsgEmergencyResolved => 'Emergência resolvida - tudo limpo'; + + @override + String get sampleMsgEmergencyStagingArea => ' Área de apoio de emergência'; + + @override + String get sampleMsgEmergencyServices => + 'Serviços de emergência notificados e em deslocamento'; + + @override + String get sampleAlphaTeamLead => 'Líder da equipe'; + + @override + String get sampleBravoScout => 'Batedor'; + + @override + String get sampleCharlieMedic => 'Paramédico'; + + @override + String get sampleDeltaNavigator => 'Navegador'; + + @override + String get sampleEchoSupport => 'Apoio'; + + @override + String get sampleBaseCommand => 'Comando da base'; + + @override + String get sampleFieldCoordinator => 'Coordenador de campo'; + + @override + String get sampleMedicalTeam => 'Equipe médica'; + + @override + String get mapDrawing => 'Desenho do mapa'; + + @override + String get navigateToDrawing => 'Navegar até o desenho'; + + @override + String get copyCoordinates => 'Copiar coordenadas'; + + @override + String get hideFromMap => 'Ocultar do mapa'; + + @override + String get lineDrawing => 'Desenho de linha'; + + @override + String get rectangleDrawing => 'Desenho de retângulo'; + + @override + String get manualCoordinates => 'Coordenadas manuais'; + + @override + String get enterCoordinatesManually => 'Inserir coordenadas manualmente'; + + @override + String get latitudeLabel => 'Latitude'; + + @override + String get longitudeLabel => 'Longitude'; + + @override + String get exampleCoordinates => 'Exemplo: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Compartilhar desenho'; + + @override + String get shareWithAllNearbyDevices => + 'Compartilhar com todos os dispositivos próximos'; + + @override + String get shareToRoom => 'Compartilhar na sala'; + + @override + String get sendToPersistentStorage => + 'Enviar para armazenamento persistente da sala'; + + @override + String get deleteDrawingConfirm => + 'Tem certeza de que deseja excluir este desenho?'; + + @override + String get drawingDeleted => 'Desenho excluído'; + + @override + String yourDrawingsCount(int count) { + return 'Seus desenhos ($count)'; + } + + @override + String get shared => 'Compartilhado'; + + @override + String get line => 'Linha'; + + @override + String get rectangle => 'Retângulo'; + + @override + String get updateAvailable => 'Atualização disponível'; + + @override + String get currentVersion => 'Atual'; + + @override + String get latestVersion => 'Mais recente'; + + @override + String get downloadUpdate => 'Baixar'; + + @override + String get updateLater => 'Mais tarde'; + + @override + String get cadastralParcels => 'Parcelas cadastrais'; + + @override + String get forestRoads => 'Estradas florestais'; + + @override + String get wmsOverlays => 'Sobreposições WMS'; + + @override + String get hikingTrails => 'Trilhas de caminhada'; + + @override + String get mainRoads => 'Estradas principais'; + + @override + String get houseNumbers => 'Números de porta'; + + @override + String get fireHazardZones => 'Zonas de risco de incêndio'; + + @override + String get historicalFires => 'Incêndios históricos'; + + @override + String get firebreaks => 'Aceiros'; + + @override + String get krasFireZones => 'Zonas de incêndio do Karst'; + + @override + String get placeNames => 'Topônimos'; + + @override + String get municipalityBorders => 'Limites municipais'; + + @override + String get topographicMap => 'Mapa topográfico 1:25000'; + + @override + String get recentMessages => 'Mensagens recentes'; + + @override + String get addChannel => 'Adicionar canal'; + + @override + String get channelName => 'Nome do canal'; + + @override + String get channelNameHint => 'ex.: Equipe de Resgate Alfa'; + + @override + String get channelSecret => 'Segredo do canal'; + + @override + String get channelSecretHint => 'Senha compartilhada para este canal'; + + @override + String get channelSecretHelp => + 'Este segredo deve ser compartilhado com todos os membros da equipe que precisam de acesso a este canal'; + + @override + String get channelTypesInfo => + 'Canais hash (#team): o segredo é gerado automaticamente a partir do nome. Mesmo nome = mesmo canal em todos os dispositivos.\n\nCanais privados: use um segredo explícito. Somente quem tiver o segredo poderá entrar.'; + + @override + String get hashChannelInfo => + 'Canal hash: o segredo será gerado automaticamente a partir do nome do canal. Qualquer pessoa usando o mesmo nome entrará no mesmo canal.'; + + @override + String get channelNameRequired => 'O nome do canal é obrigatório'; + + @override + String get channelNameTooLong => + 'O nome do canal deve ter no máximo 31 caracteres'; + + @override + String get channelSecretRequired => 'O segredo do canal é obrigatório'; + + @override + String get channelSecretTooLong => + 'O segredo do canal deve ter no máximo 32 caracteres'; + + @override + String get invalidAsciiCharacters => + 'Somente caracteres ASCII são permitidos'; + + @override + String get channelCreatedSuccessfully => 'Canal criado com sucesso'; + + @override + String channelCreationFailed(String error) { + return 'Falha ao criar canal: $error'; + } + + @override + String get deleteChannel => 'Excluir canal'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Tem certeza de que deseja excluir o canal \"$channelName\"? Esta ação não pode ser desfeita.'; + } + + @override + String get channelDeletedSuccessfully => 'Canal excluído com sucesso'; + + @override + String channelDeletionFailed(String error) { + return 'Falha ao excluir canal: $error'; + } + + @override + String get createChannel => 'Criar canal'; + + @override + String get wizardBack => 'Voltar'; + + @override + String get wizardSkip => 'Pular'; + + @override + String get wizardNext => 'Próximo'; + + @override + String get wizardGetStarted => 'Começar'; + + @override + String get wizardWelcomeTitle => 'Bem-vindo ao MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Ver tutorial de boas-vindas'; + + @override + String get allTeamContacts => 'Todos os contatos da equipe'; + + @override + String directMessagesInfo(int count) { + return 'Mensagens diretas com ACKs. Enviadas para $count membros da equipe.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'Marcador SAR enviado para $count contatos'; + } + + @override + String get noContactsAvailable => 'Nenhum contato da equipe disponível'; + + @override + String get reply => 'Responder'; + + @override + String get technicalDetails => 'Detalhes técnicos'; + + @override + String get messageTechnicalDetails => 'Detalhes técnicos da mensagem'; + + @override + String get linkQuality => 'Qualidade do link'; + + @override + String get delivery => 'Entrega'; + + @override + String get status => 'Status'; + + @override + String get expectedAckTag => 'Tag ACK esperada'; + + @override + String get roundTrip => 'Ida e volta'; + + @override + String get retryAttempt => 'Tentativa de repetição'; + + @override + String get floodFallback => 'Fallback de flooding'; + + @override + String get identity => 'Identidade'; + + @override + String get messageId => 'ID da mensagem'; + + @override + String get sender => 'Remetente'; + + @override + String get senderKey => 'Chave do remetente'; + + @override + String get recipient => 'Destinatário'; + + @override + String get recipientKey => 'Chave do destinatário'; + + @override + String get voice => 'Voz'; + + @override + String get voiceId => 'ID de voz'; + + @override + String get envelope => 'Envelope'; + + @override + String get sessionProgress => 'Progresso da sessão'; + + @override + String get complete => 'Concluído'; + + @override + String get rawDump => 'Dump bruto'; + + @override + String get cannotRetryMissingRecipient => + 'Não é possível repetir: faltam informações do destinatário'; + + @override + String get voiceUnavailable => 'A voz não está disponível no momento'; + + @override + String get requestingVoice => 'Solicitando voz'; + + @override + String get device => 'dispositivo'; + + @override + String get change => 'Alterar'; + + @override + String get wizardOverviewDescription => + 'Este aplicativo reúne mensagens MeshCore, atualizações SAR em campo, mapas e ferramentas do dispositivo em um só lugar.'; + + @override + String get wizardOverviewFeature1 => + 'Envie mensagens diretas, publicações em salas e mensagens de canal pela guia principal Mensagens.'; + + @override + String get wizardOverviewFeature2 => + 'Compartilhe marcadores SAR, desenhos de mapa, clipes de voz e imagens pela malha.'; + + @override + String get wizardOverviewFeature3 => + 'Conecte-se via BLE ou TCP e depois gerencie o rádio complementar dentro do aplicativo.'; + + @override + String get wizardMessagingTitle => 'Mensagens e relatórios de campo'; + + @override + String get wizardMessagingDescription => + 'Aqui, as mensagens são mais do que texto simples. O aplicativo já oferece suporte a várias cargas operacionais e fluxos de transferência.'; + + @override + String get wizardMessagingFeature1 => + 'Envie mensagens diretas, publicações em salas e tráfego de canal a partir de um único compositor.'; + + @override + String get wizardMessagingFeature2 => + 'Crie atualizações SAR e modelos SAR reutilizáveis para relatórios de campo comuns.'; + + @override + String get wizardMessagingFeature3 => + 'Transfira sessões de voz e imagens com progresso e estimativas de tempo de transmissão na interface.'; + + @override + String get wizardConnectDeviceTitle => 'Conectar dispositivo'; + + @override + String get wizardConnectDeviceDescription => + 'Conecte seu rádio MeshCore, escolha um nome e aplique uma predefinição de rádio antes de continuar.'; + + @override + String get wizardSetupBadge => 'Configuração'; + + @override + String get wizardOverviewBadge => 'Visão geral'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Conectado a $deviceName'; + } + + @override + String get wizardNoDeviceConnected => 'Nenhum dispositivo conectado ainda'; + + @override + String get wizardSkipForNow => 'Ignorar por enquanto'; + + @override + String get wizardDeviceNameLabel => 'Nome do dispositivo'; + + @override + String get wizardDeviceNameHelp => + 'Esse nome é anunciado para outros usuários do MeshCore.'; + + @override + String get wizardConfigRegionLabel => 'Região de configuração'; + + @override + String get wizardConfigRegionHelp => + 'Usa a lista oficial completa de predefinições do MeshCore. O padrão é EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Certifique-se de que a predefinição selecionada corresponda às regulamentações locais de rádio.'; + + @override + String get wizardPresetNote2 => + 'A lista corresponde ao feed oficial de predefinições da ferramenta de configuração do MeshCore.'; + + @override + String get wizardPresetNote3 => + 'EU/UK (Narrow) permanece selecionado por padrão durante a configuração inicial.'; + + @override + String get wizardSaving => 'Salvando...'; + + @override + String get wizardSaveAndContinue => 'Salvar e continuar'; + + @override + String get wizardEnterDeviceName => + 'Digite um nome para o dispositivo antes de continuar.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return '$deviceName salvo com $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Contatos, salas e repetidores'; + + @override + String get wizardNetworkDescription => + 'A guia Contatos organiza a rede que você descobre e as rotas aprendidas ao longo do tempo.'; + + @override + String get wizardNetworkFeature1 => + 'Revise membros da equipe, repetidores, salas, canais e anúncios pendentes em uma única lista.'; + + @override + String get wizardNetworkFeature2 => + 'Use ping inteligente, login em salas, caminhos aprendidos e ferramentas de redefinição de rota quando a conectividade ficar confusa.'; + + @override + String get wizardNetworkFeature3 => + 'Crie canais e gerencie destinos de rede sem sair do aplicativo.'; + + @override + String get wizardMapOpsTitle => 'Mapa, trilhas e geometria compartilhada'; + + @override + String get wizardMapOpsDescription => + 'O mapa do aplicativo está ligado diretamente às mensagens, ao rastreamento e às sobreposições SAR, em vez de ser um visualizador separado.'; + + @override + String get wizardMapOpsFeature1 => + 'Acompanhe sua própria posição, as localizações da equipe e as trilhas de movimento no mapa.'; + + @override + String get wizardMapOpsFeature2 => + 'Abra desenhos de mensagens, visualize-os em linha e remova-os do mapa quando necessário.'; + + @override + String get wizardMapOpsFeature3 => + 'Use vistas de mapa de repetidores e sobreposições compartilhadas para entender o alcance da rede em campo.'; + + @override + String get wizardToolsTitle => 'Ferramentas além das mensagens'; + + @override + String get wizardToolsDescription => + 'Há mais aqui do que as quatro guias principais. O aplicativo também inclui configuração, diagnóstico e fluxos opcionais de sensores.'; + + @override + String get wizardToolsFeature1 => + 'Abra a configuração do dispositivo para alterar ajustes de rádio, telemetria, potência TX e detalhes do equipamento complementar.'; + + @override + String get wizardToolsFeature2 => + 'Ative a guia Sensores quando quiser painéis monitorados e ações de atualização rápida.'; + + @override + String get wizardToolsFeature3 => + 'Use logs de pacotes, varredura de espectro e diagnóstico de desenvolvedor ao solucionar problemas da malha.'; + + @override + String get postConnectDiscoveryTitle => 'Descobrir repetidores agora?'; + + @override + String get 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.'; + + @override + String get contactInSensors => 'Nos Sensores'; + + @override + String get contactAddToSensors => 'Adicionar aos Sensores'; + + @override + String get contactSetPath => 'Definir rota'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName adicionado a Sensores'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Falha ao limpar a rota: $error'; + } + + @override + String get contactRouteCleared => 'Rota limpa'; + + @override + String contactRouteSet(String route) { + return 'Rota definida: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Falha ao definir a rota: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'Tempo limite ACK'; + + @override + String get opcode => 'Opcode'; + + @override + String get payload => 'Carga útil'; + + @override + String get hops => 'Saltos'; + + @override + String get hashSize => 'Tamanho do hash'; + + @override + String get pathBytes => 'Bytes do caminho'; + + @override + String get selectedPath => 'Caminho selecionado'; + + @override + String get estimatedTx => 'Transmissão estimada'; + + @override + String get senderToReceipt => 'Do remetente ao recibo'; + + @override + String get receivedCopies => 'Cópias recebidas'; + + @override + String get retryCause => 'Causa da nova tentativa'; + + @override + String get retryMode => 'Modo de nova tentativa'; + + @override + String get retryResult => 'Resultado da nova tentativa'; + + @override + String get lastRetry => 'Última tentativa'; + + @override + String get rxPackets => 'Pacotes RX'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Taxa'; + + @override + String get window => 'Janela'; + + @override + String get posttxDelay => 'Atraso pós-tx'; + + @override + String get bandpass => 'Passa-banda'; + + @override + String get bandpassFilterVoice => 'Filtro passa-banda de voz'; + + @override + String get active => 'Ativo'; + + @override + String get addContact => 'Adicionar contacto'; + + @override + String get all => 'Tudo'; + + @override + String get clearAllLabel => 'Limpar tudo'; + + @override + String get clearFilters => 'Limpar filtros'; + + @override + String get clearRoute => 'Limpar rota'; + + @override + String get clearMessages => 'Limpar mensagens'; + + @override + String get clearScale => 'Limpar escala'; + + @override + String get clearDiscoveries => 'Limpar descobertas'; + + @override + String get clearOnlineTraceDatabase => 'Limpar base de dados de rastreio'; + + @override + String get clearAllChannels => 'Limpar todos os canais'; + + @override + String get clearAllContacts => 'Limpar todos os contactos'; + + @override + String get clearChannels => 'Limpar canais'; + + @override + String get clearContacts => 'Limpar contactos'; + + @override + String get clearPathOnMaxRetry => 'Limpar caminho no máx. de tentativas'; + + @override + String get create => 'Criar'; + + @override + String get custom => 'Personalizado'; + + @override + String get defaultValue => 'Padrão'; + + @override + String get duplicate => 'Duplicar'; + + @override + String get editName => 'Editar nome'; + + @override + String get open => 'Abrir'; + + @override + String get paste => 'Colar'; + + @override + String get preview => 'Pré-visualização'; + + @override + String get remove => 'Remover'; + + @override + String get rename => 'Renomear'; + + @override + String get resolveAll => 'Resolver tudo'; + + @override + String get send => 'Enviar'; + + @override + String get sendAnyway => 'Enviar mesmo assim'; + + @override + String get share => 'Partilhar'; + + @override + String get trace => 'Rastreio'; + + @override + String get discovery => 'Descoberta'; + + @override + String get discoverRepeaters => 'Descobrir repetidores'; + + @override + String get discoverSensors => 'Descobrir sensores'; + + @override + String get repeaterDiscoverySent => 'Descoberta de repetidores enviada'; + + @override + String get sensorDiscoverySent => 'Descoberta de sensores enviada'; + + @override + String get clearedPendingDiscoveries => 'Descobertas pendentes limpas.'; + + @override + String get autoDiscovery => 'Descoberta automática'; + + @override + String get enableAutomaticAdding => 'Ativar adição automática'; + + @override + String get autoaddRepeaters => 'Adicionar repetidores automaticamente'; + + @override + String get autoaddRoomServers => + 'Adicionar servidores de sala automaticamente'; + + @override + String get autoaddSensors => 'Adicionar sensores automaticamente'; + + @override + String get autoaddUsers => 'Adicionar utilizadores automaticamente'; + + @override + String get overwriteOldestWhenFull => + 'Sobrescrever mais antigos quando cheio'; + + @override + String get storage => 'Armazenamento'; + + @override + String get dangerZone => 'Zona de perigo'; + + @override + String get profiles => 'Perfis'; + + @override + String get favourites => 'Favoritos'; + + @override + String get sensors => 'Sensores'; + + @override + String get others => 'Outros'; + + @override + String get gpsModule => 'Módulo GPS'; + + @override + String get liveTraffic => 'Tráfego ao vivo'; + + @override + String get repeatersMap => 'Mapa de repetidores'; + + @override + String get blePacketLogs => 'Registos de pacotes BLE'; + + @override + String get onlineTraceDatabase => 'Base de dados de rastreio'; + + @override + String get routePathByteSize => 'Tamanho do caminho em bytes'; + + @override + String get messageNotifications => 'Notificações de mensagens'; + + @override + String get sarAlerts => 'Alertas SAR'; + + @override + String get discoveryNotifications => 'Notificações de descoberta'; + + @override + String get updateNotifications => 'Notificações de atualização'; + + @override + String get muteWhileAppIsOpen => 'Silenciar com a app aberta'; + + @override + String get disableContacts => 'Desativar contactos'; + + @override + String get enableSensorsTab => 'Ativar separador Sensores'; + + @override + String get enableProfiles => 'Ativar perfis'; + + @override + String get nearestRepeaterFallback => 'Repetidor mais próximo como reserva'; + + @override + String get deleteAllStoredMessageHistory => + 'Apagar todo o histórico de mensagens'; + + @override + String get messageFontSize => 'Tamanho da fonte de mensagens'; + + @override + String get rotateMapWithHeading => 'Rodar mapa com direção'; + + @override + String get showMapDebugInfo => 'Mostrar info de depuração do mapa'; + + @override + String get openMapInFullscreen => 'Abrir mapa em ecrã inteiro'; + + @override + String get showSarMarkersLabel => 'Mostrar marcadores SAR'; + + @override + String get hideRepeatersOnMap => 'Ocultar repetidores no mapa'; + + @override + String get setMapScale => 'Definir escala do mapa'; + + @override + String get customMapScaleSaved => 'Escala de mapa personalizada guardada'; + + @override + String get voiceBitrate => 'Taxa de bits de voz'; + + @override + String get voiceCompressor => 'Compressor de voz'; + + @override + String get voiceLimiter => 'Limitador de voz'; + + @override + String get micAutoGain => 'Ganho automático do microfone'; + + @override + String get echoCancellation => 'Cancelamento de eco'; + + @override + String get noiseSuppression => 'Supressão de ruído'; + + @override + String get trimSilenceInVoiceMessages => + 'Cortar silêncio em mensagens de voz'; + + @override + String get compressor => 'Compressor'; + + @override + String get limiter => 'Limitador'; + + @override + String get autoGain => 'Ganho automático'; + + @override + String get echoCancel => 'Eco'; + + @override + String get noiseSuppress => 'Ruído'; + + @override + String get silenceTrim => 'Silêncio'; + + @override + String get maxImageSize => 'Tamanho máximo de imagem'; + + @override + String get imageCompression => 'Compressão de imagem'; + + @override + String get grayscale => 'Escala de cinzentos'; + + @override + String get ultraMode => 'Modo ultra'; + + @override + String get fastPrivateGpsUpdates => 'Atualizações GPS privadas rápidas'; + + @override + String get movementThreshold => 'Limiar de movimento'; + + @override + String get fastGpsMovementThreshold => 'Limiar de movimento GPS rápido'; + + @override + String get fastGpsActiveuseInterval => 'Intervalo de uso ativo GPS rápido'; + + @override + String get activeuseUpdateInterval => 'Intervalo de atualização de uso ativo'; + + @override + String get repeatNearbyTraffic => 'Repetir tráfego próximo'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Retransmitir através de repetidores na rede'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Apenas próximos, sem flood de repetidores'; + + @override + String get multihop => 'Multi-salto'; + + @override + String get createProfile => 'Criar perfil'; + + @override + String get renameProfile => 'Renomear perfil'; + + @override + String get newProfile => 'Novo perfil'; + + @override + String get manageProfiles => 'Gerir perfis'; + + @override + String get enableProfilesToStartManagingThem => + 'Ative os perfis para começar a geri-los.'; + + @override + String get openMessage => 'Abrir mensagem'; + + @override + String get jumpToTheRelatedSarMessage => 'Ir para a mensagem SAR relacionada'; + + @override + String get removeSarMarker => 'Remover marcador SAR'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Selecione um destino para enviar o marcador SAR'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'Marcador SAR transmitido no canal público'; + + @override + String get sarMarkerSentToRoom => 'Marcador SAR enviado para a sala'; + + @override + String get loadFromGallery => 'Carregar da galeria'; + + @override + String get replaceImage => 'Substituir imagem'; + + @override + String get selectFromGallery => 'Selecionar da galeria'; + + @override + String get team => 'Equipa'; + + @override + String get found => 'Encontrado'; + + @override + String get staging => 'Área de reunião'; + + @override + String get object => 'Objeto'; + + @override + String get quiet => 'Silencioso'; + + @override + String get busy => 'Ocupado'; + + @override + String get searchMessages => 'Pesquisar mensagens'; + + @override + String get sendImageFromGallery => 'Enviar imagem da galeria'; + + @override + String get takePhoto => 'Tirar foto'; + + @override + String get allMessages => 'Todas as mensagens'; + + @override + String get sendToPublicChannel => 'Enviar para o canal público?'; + + @override + String get selectMarkerTypeAndDestination => + 'Selecione tipo de marcador e destino'; + + @override + String get noDestinationsAvailableLabel => 'Nenhum destino disponível'; + + @override + String get image => 'Imagem'; + + @override + String get format => 'Formato'; + + @override + String get dimensions => 'Dimensões'; + + @override + String get segments => 'Segmentos'; + + @override + String get transfers => 'Transferências'; + + @override + String get downloadedBy => 'Transferido por'; + + @override + String get saveDiscoverySettings => 'Guardar definições de descoberta'; + + @override + String get savePublicInfo => 'Guardar informação pública'; + + @override + String get saveRadioSettings => 'Guardar definições de rádio'; + + @override + String get savePath => 'Guardar caminho'; + + @override + String get wipeDeviceData => 'Apagar dados do dispositivo'; + + @override + String get wipeDevice => 'Apagar dispositivo'; + + @override + String get destructiveDeviceActions => 'Ações destrutivas do dispositivo.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Escolha uma predefinição ou ajuste as definições de rádio.'; + + @override + String get used => 'Usado'; + + @override + String get total => 'Total'; + + @override + String get renameValue => 'Renomear valor'; + + @override + String get customizeFields => 'Personalizar campos'; + + @override + String get livePreview => 'Pré-visualização ao vivo'; + + @override + String get refreshSchedule => 'Agenda de atualização'; + + @override + String get noResponse => 'Sem resposta'; + + @override + String get refreshing => 'A atualizar'; + + @override + String get unavailable => 'Indisponível'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Escolha um relay ou nó para observar.'; + + @override + String get publicKeyLabel => 'Chave pública'; + + @override + String get alreadyInContacts => 'Já nos contactos'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Conecte-se a um dispositivo antes de adicionar contactos'; + + @override + String get fromContacts => 'Dos contactos'; + + @override + String get onlineOnly => 'Apenas online'; + + @override + String get inBoth => 'Em ambos'; + + @override + String get source => 'Fonte'; + + @override + String get allMessagesCleared => 'Todas as mensagens limpas'; + + @override + String get onlineTraceDatabaseCleared => 'Base de dados de rastreio limpa'; + + @override + String get packetLogsCleared => 'Registos de pacotes limpos'; + + @override + String get hexDataCopiedToClipboard => + 'Dados hex copiados para a área de transferência'; + + @override + String get developerModeEnabled => 'Modo de programador ativado'; + + @override + String get developerModeDisabled => 'Modo de programador desativado'; + + @override + String get clipboardIsEmpty => 'A área de transferência está vazia'; + + @override + String get contactImported => 'Contacto importado'; + + @override + String get contactLinkCopiedToClipboard => + 'Link do contacto copiado para a área de transferência'; + + @override + String get failedToExportContact => 'Falha ao exportar contacto'; + + @override + String get noLogsToExport => 'Sem registos para exportar'; + + @override + String get exportAsCsv => 'Exportar como CSV'; + + @override + String get exportAsText => 'Exportar como texto'; + + @override + String get receivedRfc3339 => 'Recebido (RFC3339)'; + + @override + String get buildTime => 'Hora de compilação'; + + @override + String get downloadUrlNotAvailable => 'URL de download não disponível'; + + @override + String get cannotOpenDownloadUrl => 'Não é possível abrir o URL de download'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'A verificação de atualizações só está disponível no Android'; + + @override + String get youAreRunningTheLatestVersion => + 'Está a usar a versão mais recente'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Atualização disponível mas URL de download não encontrado'; + + @override + String get startTictactoe => 'Iniciar Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe indisponível'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: oponente desconhecido'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: a aguardar início'; + + @override + String get acceptsShareLinks => 'Aceita links partilhados'; + + @override + String get supportsRawHex => 'Suporta hex bruto'; + + @override + String get clipboardfriendly => 'Compatível com a área de transferência'; + + @override + String get captured => 'Capturado'; + + @override + String get size => 'Tamanho'; + + @override + String get noCustomChannelsToClear => + 'Sem canais personalizados para limpar.'; + + @override + String get noDeviceContactsToClear => + 'Sem contactos do dispositivo para limpar.'; + + @override + String get aToZ => 'A-Z'; + + @override + String get add => 'Adicionar'; + + @override + String get addAdditionalDetails => 'Adicionar detalhes adicionais'; + + @override + String get addPeer => 'Adicionar par'; + + @override + String get addPeerManually => 'Adicionar par manualmente'; + + @override + String addedToContacts(String name) { + return '$name adicionado aos contatos'; + } + + @override + String addedToSensors(String name) { + return '$name adicionado aos Sensores'; + } + + @override + String get allowAll => 'Permitir todos'; + + @override + String get anonymousRxStats => 'Estatísticas RX anónimas'; + + @override + String get appearance => 'Aparência'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Telemetria base'; + + @override + String get blePin => 'PIN BLE'; + + @override + String get buzzerAlerts => 'Alertas sonoros'; + + @override + String get cancelImageReceive => 'Cancelar receção de imagem'; + + @override + String get choose => 'Escolher'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Escolher quais separadores e secções de contatos mostrar'; + + @override + String get clearCache => 'Limpar cache'; + + @override + String get clearHistory => 'Limpar histórico'; + + @override + String get clearLiveView => 'Limpar vista ao vivo'; + + @override + String get clearLogs => 'Limpar registos'; + + @override + String get clearOfflineCache => 'Limpar cache offline?'; + + @override + String get clearOverlay => 'Limpar sobreposição'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Limpar rota apenas após todas as tentativas falharem'; + + @override + String get codecBitrateAndAudioProcessing => + 'Codec, bitrate e processamento de áudio'; + + @override + String get connectByIpAddress => 'Conectar por endereço IP'; + + @override + String get controlWhichAlertsYouReceive => 'Controlar quais alertas recebe'; + + @override + String get copyFullHex => 'Copiar hex completo'; + + @override + String get copyRawResponse => 'Copiar resposta bruta'; + + @override + String get couldNotLoadMetHistory => + 'Não foi possível carregar o histórico MET'; + + @override + String customizeSensor(String name) { + return 'Personalizar $name'; + } + + @override + String get data => 'Dados'; + + @override + String get deleteGroup => 'Eliminar grupo'; + + @override + String deleteStyleConfirm(String name) { + return 'Eliminar $name?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Eliminar este $type?'; + } + + @override + String get deny => 'Negar'; + + @override + String get deviceInfo => 'Informações do dispositivo'; + + @override + String get deviceSettings => 'Definições do dispositivo'; + + @override + String get deviceTimeSynced => + 'Hora do dispositivo sincronizada com este telemóvel.'; + + @override + String get disableFastGpsPublishing => 'Desativar publicação rápida de GPS'; + + @override + String get displayMarkersAndTraceDatabase => + 'Ecrã, marcadores e base de dados de percursos'; + + @override + String get download => 'Transferir'; + + @override + String get environmentalTelemetry => 'Telemetria ambiental'; + + @override + String errorCheckingForUpdates(String error) { + return 'Erro ao verificar atualizações: $error'; + } + + @override + String errorGeneric(String error) { + return 'Erro: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Erro ao abrir transferência: $error'; + } + + @override + String get exportLogs => 'Exportar registos'; + + @override + String get extraAggressiveAvifCompression => + 'Compressão AVIF extra-agressiva'; + + @override + String failedToAddContact(String error) { + return 'Falha ao adicionar contato: $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Falha ao adicionar $name: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Falha ao limpar canais: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Falha ao limpar contatos: $error'; + } + + @override + String get failedToConnectViaSerial => 'Falha ao conectar via série'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Falha ao carregar imagem de pré-visualização: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Falha ao carregar percurso: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Falha ao enviar marcador SAR: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Falha ao definir modo de alarme: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Falha ao definir modo GPS: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Falha ao sincronizar hora do dispositivo: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Falha ao apagar dados do dispositivo: $error'; + } + + @override + String get fastGpsTargetChannel => 'Canal alvo de GPS rápido'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Atualizações rápidas, limiares e permissões'; + + @override + String get fetchingCatalog => 'A obter catálogo...'; + + @override + String get fifteenMinutes => '15 minutos'; + + @override + String get fifteenSeconds => '15 segundos'; + + @override + String get filterByDirection => 'Filtrar por direção'; + + @override + String get finish => 'Concluir'; + + @override + String get fiveMinutes => '5 minutos'; + + @override + String get fiveSeconds => '5 segundos'; + + @override + String get fix => 'Correção'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'Política de anúncio GPS'; + + @override + String get gpsAndLocation => 'GPS e Localização'; + + @override + String get gpsDiagnostics => 'Diagnósticos GPS'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Ajuda'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Oculto'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Como o rádio adiciona automaticamente nós descobertos.'; + + @override + String get images => 'Imagens'; + + @override + String get importProfile => 'Importar perfil'; + + @override + String get lastFix => 'Última correção'; + + @override + String get latest => 'Mais recente'; + + @override + String get latitude => 'Latitude'; + + @override + String get loadASavedRegion => 'Carregar uma região guardada'; + + @override + String get loading => 'A carregar'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Partilha de localização, hardware e intervalo de atualização.'; + + @override + String get locationTelemetry => 'Telemetria de localização'; + + @override + String get lockDestination => 'Bloquear destino'; + + @override + String get longitude => 'Longitude'; + + @override + String get mapStyle => 'Estilo do mapa'; + + @override + String get max => 'Máx'; + + @override + String get maxZoom => 'Zoom máximo'; + + @override + String get messaging => 'Mensagens'; + + @override + String get metHistory => 'Histórico MET'; + + @override + String get min => 'Mín'; + + @override + String get minZoom => 'Zoom mínimo'; + + @override + String get moveDown => 'Mover para baixo'; + + @override + String get moveUp => 'Mover para cima'; + + @override + String get multiAckMode => 'Modo multi-ACK'; + + @override + String get multiDeviceWorkspaceManagement => + 'Gestão de espaço de trabalho multi-dispositivo'; + + @override + String get myLocation => 'A minha localização'; + + @override + String get nameAndTelemetryShared => + 'Nome e telemetria partilhados com outros dispositivos.'; + + @override + String newVersionAvailable(String version) { + return 'Nova versão: $version'; + } + + @override + String get newer => 'Mais recente'; + + @override + String get noNeighboursFound => 'Nenhum vizinho encontrado'; + + @override + String get noServersFound => 'Nenhum servidor encontrado'; + + @override + String get notSet => 'Não definido'; + + @override + String get notifications => 'Notificações'; + + @override + String get offZeroSeconds => 'Desligado (0 s)'; + + @override + String get offlineMaps => 'Mapas offline'; + + @override + String get older => 'Mais antigo'; + + @override + String get oneByteMode0 => '1 byte (modo 0)'; + + @override + String get oneHour => '1 hora'; + + @override + String get oneMinute => '1 minuto'; + + @override + String get openPacketLogs => 'Abrir registos de pacotes'; + + @override + String get packetTypeHelp => 'Ajuda sobre tipos de pacotes'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Colar um link de partilha ou anúncio hex'; + + @override + String get pathHash => 'Hash do caminho'; + + @override + String get pathSize => 'Tamanho do caminho'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Polígono'; + + @override + String get rawResponseCopied => 'Resposta bruta copiada'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Reenviar pelo repetidor mais próximo em caso de falha'; + + @override + String get resolveContact => 'Resolver contato'; + + @override + String get routingRetriesAndDestinationLock => + 'Encaminhamento, tentativas e bloqueio de destino'; + + @override + String get samples => 'Amostras'; + + @override + String get satellites => 'Satélites'; + + @override + String get saving => 'A guardar...'; + + @override + String get search => 'Pesquisar'; + + @override + String get searchByNameOrToken => 'Pesquisar por nome ou token'; + + @override + String get searchDiscoveredNodes => 'Pesquisar nós descobertos'; + + @override + String get searchInCurrentFilter => 'Pesquisar no filtro atual'; + + @override + String get searchLogs => 'Pesquisar registos...'; + + @override + String get searchSensors => 'Pesquisar sensores'; + + @override + String get searchThisSection => 'Pesquisar nesta secção'; + + @override + String get selectedRelays => 'Repetidores selecionados'; + + @override + String get sensor => 'Sensor'; + + @override + String serialError(String error) { + return 'Erro de série: $error'; + } + + @override + String setPathForContact(String name) { + return 'Definir caminho para $name'; + } + + @override + String get shareLiveGps => 'Partilhar GPS ao vivo'; + + @override + String get shareMyTiles => 'Partilhar os meus mosaicos'; + + @override + String get sharingLocationFromPhone => + 'A partilhar localização neste canal a partir do telemóvel.'; + + @override + String get sharingLocationFromRadio => + 'A partilhar localização neste canal a partir do rádio.'; + + @override + String signalDbm(String rssi) { + return 'Sinal $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => + 'Tamanho, compressão e pré-visualização'; + + @override + String get simpleMode => 'Modo simples'; + + @override + String get simpleModeDescription => + 'Apenas chat e mapa com contatos favoritos'; + + @override + String get simpleModeRequiresLockedDestination => + 'Ative primeiro \"Bloquear destino\" com um canal para usar o modo simples'; + + @override + String get noFavouriteContacts => 'Nenhum contato favorito'; + + @override + String get skip => 'Saltar'; + + @override + String get smallerFileSize => 'Tamanho de ficheiro menor'; + + @override + String get sort => 'Ordenar'; + + @override + String get stoppedSharingLocation => + 'Deixou de partilhar localização neste canal.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Suprimir notificações em primeiro plano'; + + @override + String get tabsAndNavigation => 'Separadores e Navegação'; + + @override + String get templatesAndTutorials => 'Modelos e tutoriais'; + + @override + String get tenMinutes => '10 minutos'; + + @override + String get tenSeconds => '10 segundos'; + + @override + String get testSendUpdate => 'Testar envio de atualização'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Tema, idioma e preferências de apresentação'; + + @override + String get thirtyMinutes => '30 minutos'; + + @override + String get thirtySeconds => '30 segundos'; + + @override + String get threeBytesMode2 => '3 bytes (modo 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Estatísticas de tráfego, histórico de mensagens e ferramentas de programador'; + + @override + String get twoBytesMode1 => '2 bytes (modo 1)'; + + @override + String get twoMinutes => '2 minutos'; + + @override + String get undo => 'Desfazer'; + + @override + String get updated => 'Atualizado'; + + @override + String get useContactFlags => 'Usar bandeiras de contato'; + + @override + String get useSavedCoordinates => 'Usar coordenadas guardadas'; + + @override + String get view => 'Ver'; + + @override + String get viewNeighbours => 'Ver vizinhos'; + + @override + String get viewPublicStats => 'Ver estatísticas públicas'; + + @override + String get setRegionScope => 'Definir âmbito de região'; + + @override + String get regionScope => 'Âmbito de região'; + + @override + String get regionScopeNone => 'Nenhum (global)'; + + @override + String get clearRegionScope => 'Limpar âmbito'; + + @override + String get regionScopeWarning => + 'Apenas os repetidores que permitem esta região irão retransmitir.'; + + @override + String get discoverRegions => 'Descobrir a partir dos repetidores'; + + @override + String get discoveringRegions => 'A descobrir regiões...'; + + @override + String get enterRegionName => 'Nome da região (ex. auckland)'; + + @override + String get noRegionsFound => 'Nenhuma região encontrada neste repetidor.'; + + @override + String regionScopeSet(String name) { + return 'Âmbito de região definido para $name'; + } + + @override + String get regionScopeCleared => 'Âmbito de região limpo'; + + @override + String get selectDestinationFirst => + 'Selecione primeiro um canal, contato ou sala'; + + @override + String get recipientNoLongerAvailable => + 'O destinatário não está mais disponível'; + + @override + String failedToSendMessage(String error) { + return 'Falha ao enviar: $error'; + } + + @override + String get tictactoeDirectOnly => + 'O jogo da velha funciona apenas em mensagens diretas. Escolha primeiro um contato.'; + + @override + String get deviceKeyUnavailable => + 'A chave do dispositivo não está disponível'; + + @override + String get imageCompressionFailed => 'Falha na compressão da imagem'; + + @override + String get imageFragmentationFailed => 'Falha na fragmentação da imagem'; + + @override + String get failedToAnnounceImage => 'Falha ao anunciar a imagem'; + + @override + String get noRecipientSelected => 'Nenhum destinatário selecionado'; + + @override + String get imageSendFailed => 'Falha no envio da imagem'; + + @override + String get microphonePermissionRequiredForVoice => + 'É necessária permissão de microfone para mensagens de voz'; + + @override + String get recordingDiscarded => 'Gravação descartada'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Você está prestes a enviar $mediaType para o canal público. Isso não é recomendado, pois todos na rede mesh podem receber. Escolha um canal privado ou com tag, a menos que seja isso que você quer.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Selecione um canal privado para compartilhar sua localização.'; + + @override + String get stopSharingMyLocation => 'Parar de compartilhar minha localização'; + + @override + String get shareMyLocation => 'Compartilhar minha localização'; + + @override + String get stopRecording => 'Parar gravação'; + + @override + String get recordVoice => 'Gravar mensagem de voz'; + + @override + String get moreActions => 'Mais ações'; + + @override + String get noMessagesInFilter => 'Nenhuma mensagem neste filtro'; + + @override + String get noMatchesInFilter => 'Nenhum resultado neste filtro'; + + @override + String syncFailed(String error) { + return 'Falha na sincronização: $error'; + } + + @override + String get invalidLink => 'Link inválido'; + + @override + String get cannotOpenLink => 'Não é possível abrir o link'; + + @override + String get failedToOpenLink => 'Falha ao abrir o link'; + + @override + String get failedToResendMessage => 'Falha ao reenviar a mensagem'; + + @override + String retryFailed(String error) { + return 'Falha na nova tentativa: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Excluir desenho e mensagem'; + + @override + String get lastEchoRelay => 'Último eco: repetidor'; + + @override + String get lastEchoPath => 'Último eco: caminho'; + + @override + String get lastEchoBytesReport => 'Último eco: relatório de bytes'; + + @override + String get noGpsCoordinatesAvailable => 'Nenhuma coordenada GPS disponível'; + + @override + String get notASarMarker => 'Não é um marcador SAR'; + + @override + String failedToSaveTemplate(String error) { + return 'Falha ao salvar o modelo: $error'; + } + + @override + String get drawingNotFound => 'Desenho não encontrado'; + + @override + String get unknownDrawingType => 'Tipo de desenho desconhecido'; + + @override + String get customMapMarker => 'Marcador de mapa personalizado'; + + @override + String customMapPointLabel(String point) { + return 'Ponto: $point'; + } + + @override + String mapIdLabel(String id) { + return 'ID do mapa: $id'; + } + + @override + String get cannotFetchVoice => 'Não é possível obter a voz'; + + @override + String get senderContactUnknown => + 'O contato do remetente é desconhecido. Sincronize os contatos primeiro.'; + + @override + String get senderRouteUnknown => + 'A rota do remetente é desconhecida. Sincronize os contatos/o caminho primeiro.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'A mensagem está longe demais ($hops saltos, máx. $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'A rota do remetente não respondeu à verificação de caminho. Sincronize os contatos/o caminho e tente novamente.'; + + @override + String get senderRouteNoRawResponse => + 'A rota do remetente não respondeu no caminho de transporte raw.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Obter a voz por $hops saltos pode demorar um pouco.'; + } + + @override + String get voiceReceiveCanceled => 'Recebimento de voz cancelado'; + + @override + String get fetchingMissingVoiceFragments => + 'Obtendo fragmentos de voz ausentes'; + + @override + String get receivingVoice => 'Recebendo voz'; + + @override + String get cannotFetchImage => 'Não é possível obter a imagem'; + + @override + String imageFetchOverHops(String hops) { + return 'Obter a imagem por $hops saltos pode demorar um pouco.'; + } + + @override + String get imageAlreadyBeingReceived => 'A imagem já está sendo recebida'; + + @override + String get loadImage => 'Carregar imagem'; + + @override + String get imageFetchFailedToSendRequest => + 'Falha ao enviar a solicitação da imagem'; + + @override + String get imageUnavailable => 'Imagem indisponível no momento'; + + @override + String get imageFetchTimedOut => 'Tempo esgotado ao obter a imagem'; + + @override + String get imageReceiveCanceled => 'Recebimento da imagem cancelado'; + + @override + String get closeImagePreview => 'Fechar visualização da imagem'; + + @override + String get recordingReleaseToSend => 'Gravando... solte para enviar'; + + @override + String get sendingVoice => 'Enviando voz...'; + + @override + String get sendLongPressToRecordVoice => + 'Enviar (toque longo para gravar voz)'; + + @override + String get discardRecording => 'Descartar gravação'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count saltos', + one: '1 salto', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Canais de difusão para o tráfego mesh próximo'; + + @override + String get roomsSectionDescription => + 'Espaços compartilhados para coordenação contínua da equipe'; + + @override + String get contactsSectionDescription => + 'Pessoas e dispositivos ao seu alcance direto'; + + @override + String get noRecentChatters => 'Sem participantes recentes'; + + @override + String get public => 'Público'; + + @override + String get failedToSendTicTacToeMove => + 'Falha ao enviar a jogada do jogo da velha'; + + @override + String get youWon => 'Você venceu'; + + @override + String get opponentWon => 'O adversário venceu'; + + @override + String get gameDraw => 'Empate'; + + @override + String get yourTurn => 'Sua vez'; + + @override + String get opponentTurn => 'Vez do adversário'; + + @override + String get pleaseEnterValidCoordinates => 'Insira coordenadas válidas'; + + @override + String get locationNotAvailable => 'Localização não disponível'; + + @override + String get customCaveMapPoint => 'Ponto de mapa de caverna personalizado'; + + @override + String get mapPoint => 'Ponto do mapa'; + + @override + String get fetchingMissingImageFragments => 'Obtendo fragmentos ausentes…'; + + @override + String get loadingImage => 'Carregando…'; + + @override + String get receivingImage => 'Recebendo…'; + + @override + String get tapToLoad => 'Toque para carregar'; + + @override + String get connectDevice => 'Conectar dispositivo'; + + @override + String get chooseTransportSubtitle => + 'Escolha o transporte Bluetooth, WiFi ou Serial'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Rede'; + + @override + String get serial => 'Serial'; + + @override + String get scan => 'Escanear'; + + @override + String get pressScanToSearchForDevices => + 'Pressione escanear para procurar dispositivos próximos'; + + @override + String failedToConnectToDevice(String name) { + return 'Falha ao conectar a $name'; + } + + @override + String get unknownDevice => 'Dispositivo desconhecido'; + + @override + String get showingCachedResultsTapRefresh => + 'Exibindo resultados em cache. Toque em atualizar para escanear novamente.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Escaneando a rede local em busca de dispositivos MeshCore WiFi na porta $port'; + } + + @override + String get cancelScanAndAddServer => + 'Cancelar escaneamento e adicionar servidor'; + + @override + String get addServer => 'Adicionar servidor'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Escaneando... $scanned/$total IPs'; + } + + @override + String get cancelAndEnterManually => 'Cancelar e inserir manualmente'; + + @override + String get noRecentOrDiscoveredServers => + 'Ainda não há servidores recentes ou descobertos'; + + @override + String get recentlyUsed => 'Usados recentemente'; + + @override + String get connecting => 'Conectando...'; + + @override + String get discoveredOnThisNetwork => 'Descobertos nesta rede'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'O servidor em $host:$port não está mais disponível. Escaneie novamente para encontrar servidores ativos.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Falha ao conectar a $host:$port'; + } + + @override + String get enterValidIpAddress => 'Insira um endereço IP válido'; + + @override + String get enterValidTcpPort => 'Insira uma porta TCP válida'; + + @override + String get ipAddress => 'Endereço IP'; + + @override + String get tcpPort => 'Porta TCP'; + + @override + String get customServerPort => 'Porta de servidor personalizada'; + + @override + String get chat => 'Chat'; + + @override + String get restoringPreviousLink => 'Restaurando a conexão anterior'; + + @override + String get noDeviceConnected => 'Nenhum dispositivo conectado'; + + @override + String get sendAdvert => 'Enviar meu contato'; + + @override + String get advertMode => 'Enviar meu contato'; + + @override + String get advertModeSubtitle => + 'Escolha até onde suas informações de contato devem chegar.'; + + @override + String get floodAdvertSent => 'Contato enviado por toda a rede mesh'; + + @override + String get directAdvertSent => + 'Contato enviado para os dispositivos próximos'; +} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart new file mode 100644 index 0000000..bcc8fcb --- /dev/null +++ b/lib/l10n/app_localizations_ru.dart @@ -0,0 +1,3447 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Сообщения'; + + @override + String get contacts => 'Контакты'; + + @override + String get map => 'Карта'; + + @override + String get settings => 'Настройки'; + + @override + String get connect => 'Подключить'; + + @override + String get disconnect => 'Отключить'; + + @override + String get noDevicesFound => 'Устройства не найдены'; + + @override + String get scanAgain => 'Повторить поиск'; + + @override + String get deviceNotConnected => 'Устройство не подключено'; + + @override + String get locationPermissionDenied => 'Доступ к геолокации запрещён'; + + @override + String get locationPermissionPermanentlyDenied => + 'Доступ к геолокации запрещён навсегда. Включите его в настройках.'; + + @override + String get locationPermissionRequired => + 'Доступ к геолокации необходим для GPS-трекинга и координации команды. Вы можете включить его позже в настройках.'; + + @override + String get locationServicesDisabled => + 'Службы геолокации отключены. Пожалуйста, включите их в настройках.'; + + @override + String get failedToGetGpsLocation => 'Не удалось получить GPS-координаты'; + + @override + String failedToAdvertise(String error) { + return 'Ошибка трансляции: $error'; + } + + @override + String get cancelReconnection => 'Отменить переподключение'; + + @override + String get general => 'Основные'; + + @override + String get theme => 'Тема'; + + @override + String get chooseTheme => 'Выбрать тему'; + + @override + String get light => 'Светлая'; + + @override + String get dark => 'Тёмная'; + + @override + String get blueLightTheme => 'Синяя светлая тема'; + + @override + String get blueDarkTheme => 'Синяя тёмная тема'; + + @override + String get sarRed => 'SAR Красная'; + + @override + String get alertEmergencyMode => 'Режим тревоги / ЧС'; + + @override + String get sarGreen => 'SAR Зелёная'; + + @override + String get safeAllClearMode => 'Режим «Всё в порядке»'; + + @override + String get autoSystem => 'Авто (Система)'; + + @override + String get followSystemTheme => 'Следовать системной теме'; + + @override + String get showRxTxIndicators => 'Показывать индикаторы RX/TX'; + + @override + String get disableMap => 'Отключить карту'; + + @override + String get language => 'Язык'; + + @override + String get chooseLanguage => 'Выбрать язык'; + + @override + String get save => 'Сохранить'; + + @override + String get cancel => 'Отмена'; + + @override + String get close => 'Закрыть'; + + @override + String get about => 'О приложении'; + + @override + String get appVersion => 'Версия приложения'; + + @override + String get aboutMeshCoreSar => 'О MeshCore SAR'; + + @override + String get aboutDescription => + 'Приложение для поисково-спасательных операций, разработанное для аварийно-спасательных служб. Возможности:\n\n• BLE mesh-сеть для связи между устройствами\n• Офлайн-карты с несколькими вариантами слоёв\n• Отслеживание членов команды в реальном времени\n• Тактические маркеры SAR (найденный человек, пожар, место сбора)\n• Управление контактами и обмен сообщениями\n• GPS-трекинг с показанием курса компаса\n• Кэширование тайлов карт для работы офлайн'; + + @override + String get technologiesUsed => 'Используемые технологии:'; + + @override + String get technologiesList => + '• Flutter для кросс-платформенной разработки\n• BLE (Bluetooth Low Energy) для mesh-сети\n• OpenStreetMap для карт\n• Provider для управления состоянием\n• SharedPreferences для локального хранилища'; + + @override + String get moreInfo => 'Подробнее'; + + @override + String get packageName => 'Имя пакета'; + + @override + String get sampleData => 'Тестовые данные'; + + @override + String get sampleDataDescription => + 'Загрузить или очистить тестовые контакты, сообщения каналов и маркеры SAR'; + + @override + String get loadSampleData => 'Загрузить тестовые данные'; + + @override + String get clearAllData => 'Очистить все данные'; + + @override + String get clearAllDataConfirmTitle => 'Очистить все данные'; + + @override + String get clearAllDataConfirmMessage => + 'Это удалит все контакты и маркеры SAR. Вы уверены?'; + + @override + String get clear => 'Очистить'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Загружено: $teamCount членов команды, $channelCount каналов, $sarCount маркеров SAR, $messageCount сообщений'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Не удалось загрузить тестовые данные: $error'; + } + + @override + String get allDataCleared => 'Все данные очищены'; + + @override + String get failedToStartBackgroundTracking => + 'Не удалось запустить фоновое отслеживание. Проверьте разрешения и BLE-соединение.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Трансляция геолокации: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'PIN-код по умолчанию для устройств без экрана — 123456. Проблемы с сопряжением? Удалите устройство из Bluetooth в системных настройках.'; + + @override + String get noMessagesYet => 'Сообщений пока нет'; + + @override + String get pullDownToSync => 'Потяните вниз для синхронизации сообщений'; + + @override + String get deleteContact => 'Удалить контакт'; + + @override + String get delete => 'Удалить'; + + @override + String get viewOnMap => 'Показать на карте'; + + @override + String get refresh => 'Обновить'; + + @override + String get resetPath => 'Сбросить маршрут (перепроложить)'; + + @override + String copiedToClipboard(String label) { + return '$label скопировано в буфер обмена'; + } + + @override + String get pleaseEnterPassword => 'Пожалуйста, введите пароль'; + + @override + String failedToSyncContacts(String error) { + return 'Не удалось синхронизировать контакты: $error'; + } + + @override + String get loggedInSuccessfully => + 'Вход выполнен! Ожидание сообщений комнаты...'; + + @override + String get loginFailed => 'Ошибка входа — неверный пароль'; + + @override + String loggingIn(String roomName) { + return 'Вход в $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Не удалось отправить данные входа: $error'; + } + + @override + String get lowLocationAccuracy => 'Низкая точность геолокации'; + + @override + String get continue_ => 'Продолжить'; + + @override + String get sendSarMarker => 'Отправить маркер SAR'; + + @override + String get deleteDrawing => 'Удалить рисунок'; + + @override + String get drawingTools => 'Инструменты рисования'; + + @override + String get drawLine => 'Нарисовать линию'; + + @override + String get drawLineDesc => 'Нарисуйте произвольную линию на карте'; + + @override + String get drawRectangle => 'Нарисовать прямоугольник'; + + @override + String get drawRectangleDesc => 'Нарисуйте прямоугольную область на карте'; + + @override + String get measureDistance => 'Измерить расстояние'; + + @override + String get measureDistanceDesc => 'Долгое нажатие на две точки для измерения'; + + @override + String get clearMeasurement => 'Сбросить измерение'; + + @override + String distanceLabel(String distance) { + return 'Расстояние: $distance'; + } + + @override + String get longPressForSecondPoint => 'Долгое нажатие для второй точки'; + + @override + String get longPressToStartMeasurement => + 'Долгое нажатие для установки первой точки'; + + @override + String get longPressToStartNewMeasurement => + 'Долгое нажатие для начала нового измерения'; + + @override + String get shareDrawings => 'Поделиться рисунками'; + + @override + String get clearAllDrawings => 'Удалить все рисунки'; + + @override + String get completeLine => 'Завершить линию'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Транслировать $count рисун$plural команде'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Удалить все $count рисун$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Удалить все $count рисун$plural с карты?'; + } + + @override + String get drawing => 'Рисунок'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Поделиться $count рисун$plural'; + } + + @override + String get showReceivedDrawings => 'Показать полученные рисунки'; + + @override + String get showingAllDrawings => 'Показаны все рисунки'; + + @override + String get showingOnlyYourDrawings => 'Показаны только ваши рисунки'; + + @override + String get showSarMarkers => 'Показать маркеры SAR'; + + @override + String get showingSarMarkers => 'Маркеры SAR отображаются'; + + @override + String get hidingSarMarkers => 'Маркеры SAR скрыты'; + + @override + String get clearAll => 'Очистить всё'; + + @override + String get publicChannel => 'Публичный канал'; + + @override + String get broadcastToAll => 'Трансляция всем ближайшим узлам (временно)'; + + @override + String get storedPermanently => 'Сохранено постоянно в комнате'; + + @override + String get notConnectedToDevice => 'Устройство не подключено'; + + @override + String get typeYourMessage => 'Введите сообщение...'; + + @override + String get quickLocationMarker => 'Быстрый маркер местоположения'; + + @override + String get markerType => 'Тип маркера'; + + @override + String get sendTo => 'Отправить в'; + + @override + String get noDestinationsAvailable => 'Нет доступных получателей.'; + + @override + String get selectDestination => 'Выберите получателя...'; + + @override + String get ephemeralBroadcastInfo => + 'Временно: передаётся по эфиру. Не сохраняется — узлы должны быть онлайн.'; + + @override + String get persistentRoomInfo => + 'Постоянно: хранится неизменно в комнате. Синхронизируется автоматически и доступно офлайн.'; + + @override + String get location => 'Местоположение'; + + @override + String get fromMap => 'С карты'; + + @override + String get gettingLocation => 'Получение местоположения...'; + + @override + String get locationError => 'Ошибка геолокации'; + + @override + String get retry => 'Повторить'; + + @override + String get refreshLocation => 'Обновить местоположение'; + + @override + String accuracyMeters(int accuracy) { + return 'Точность: ±$accuracyм'; + } + + @override + String get notesOptional => 'Заметки (необязательно)'; + + @override + String get addAdditionalInformation => + 'Добавьте дополнительную информацию...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'Точность геолокации: ±$accuracyм. Этого может быть недостаточно для операций SAR.\n\nПродолжить всё равно?'; + } + + @override + String get loginToRoom => 'Войти в комнату'; + + @override + String get enterPasswordInfo => + 'Введите пароль для доступа к этой комнате. Пароль будет сохранён для дальнейшего использования.'; + + @override + String get password => 'Пароль'; + + @override + String get enterRoomPassword => 'Введите пароль комнаты'; + + @override + String get loggingInDots => 'Вход...'; + + @override + String get login => 'Войти'; + + @override + String failedToAddRoom(String error) { + return 'Не удалось добавить комнату на устройство: $error\n\nВозможно, комната ещё не объявила себя.\nПопробуйте подождать, пока комната не выйдет на связь.'; + } + + @override + String get direct => 'Напрямую'; + + @override + String get flood => 'Широковещательно'; + + @override + String get autoSend => 'Автоотправка'; + + @override + String get autoSendDescription => 'Отправить текущим маршрутом.'; + + @override + String get sendDirect => 'Прямая отправка'; + + @override + String get sendDirectDescription => 'Отправить напрямую этому контакту.'; + + @override + String get sendFlood => 'Отправка лавиной'; + + @override + String get sendFloodDescription => 'Отправить через все ретрансляторы.'; + + @override + String get loggedIn => 'Вход выполнен'; + + @override + String get noGpsData => 'Нет данных GPS'; + + @override + String get distance => 'Расстояние'; + + @override + String directPingTimeout(String name) { + return 'Таймаут прямого пинга — повтор $name широковещательно...'; + } + + @override + String pingFailed(String name) { + return 'Пинг не удался: $name — ответ не получен'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Вы уверены, что хотите удалить \"$name\"?\n\nЭто удалит контакт как из приложения, так и с сопряжённого радиоустройства.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Не удалось удалить контакт: $error'; + } + + @override + String get type => 'Тип'; + + @override + String get publicKey => 'Публичный ключ'; + + @override + String get lastSeen => 'Последнее появление'; + + @override + String get yes => 'Да'; + + @override + String get no => 'Нет'; + + @override + String get permissions => 'Разрешения'; + + @override + String get telemetry => 'Телеметрия'; + + @override + String get voltage => 'Напряжение'; + + @override + String get battery => 'Батарея'; + + @override + String get temperature => 'Температура'; + + @override + String get humidity => 'Влажность'; + + @override + String get pressure => 'Давление'; + + @override + String get gpsTelemetry => 'GPS (телеметрия)'; + + @override + String get reLoginToRoom => 'Войти в комнату повторно'; + + @override + String get heading => 'Курс'; + + @override + String get elevation => 'Высота'; + + @override + String get accuracy => 'Точность'; + + @override + String get bearing => 'Азимут'; + + @override + String get direction => 'Направление'; + + @override + String get filterMarkers => 'Фильтр маркеров'; + + @override + String get filterMarkersTooltip => 'Фильтровать маркеры'; + + @override + String get contactsFilter => 'Контакты'; + + @override + String get repeatersFilter => 'Ретрансляторы'; + + @override + String get sarMarkers => 'Маркеры SAR'; + + @override + String get foundPerson => 'Найденный человек'; + + @override + String get fire => 'Пожар'; + + @override + String get stagingArea => 'Место сбора'; + + @override + String get showAll => 'Показать все'; + + @override + String get locationUnavailable => 'Местоположение недоступно'; + + @override + String get ahead => 'впереди'; + + @override + String degreesRight(int degrees) { + return '$degrees° вправо'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° влево'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Ш: $latitude Д: $longitude'; + } + + @override + String get noContactsYet => 'Контактов пока нет'; + + @override + String get connectToDeviceToLoadContacts => + 'Подключите устройство для загрузки контактов'; + + @override + String get teamMembers => 'Члены команды'; + + @override + String get repeaters => 'Ретрансляторы'; + + @override + String get rooms => 'Комнаты'; + + @override + String get channels => 'Каналы'; + + @override + String get selectMapLayer => 'Выбрать слой карты'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'ESRI Спутник'; + + @override + String get googleHybrid => 'Google Гибрид'; + + @override + String get googleRoadmap => 'Google Дороги'; + + @override + String get googleTerrain => 'Google Рельеф'; + + @override + String get dragToPosition => 'Перетащите для позиционирования'; + + @override + String get createSarMarker => 'Создать маркер SAR'; + + @override + String get compass => 'Компас'; + + @override + String get navigationAndContacts => 'Навигация и контакты'; + + @override + String get sarAlert => 'ТРЕВОГА SAR'; + + @override + String get textCopiedToClipboard => 'Текст скопирован в буфер обмена'; + + @override + String get cannotReplySenderMissing => + 'Не удаётся ответить: нет информации об отправителе'; + + @override + String get cannotReplyContactNotFound => + 'Не удаётся ответить: контакт не найден'; + + @override + String get copyText => 'Копировать текст'; + + @override + String get saveAsTemplate => 'Сохранить как шаблон'; + + @override + String get templateSaved => 'Шаблон успешно сохранён'; + + @override + String get templateAlreadyExists => 'Шаблон с таким эмодзи уже существует'; + + @override + String get deleteMessage => 'Удалить сообщение'; + + @override + String get deleteMessageConfirmation => + 'Вы уверены, что хотите удалить это сообщение?'; + + @override + String get shareLocation => 'Поделиться местоположением'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nКоординаты: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'Местоположение SAR'; + + @override + String get justNow => 'Только что'; + + @override + String minutesAgo(int minutes) { + return '$minutesм назад'; + } + + @override + String hoursAgo(int hours) { + return '$hoursч назад'; + } + + @override + String daysAgo(int days) { + return '$daysд назад'; + } + + @override + String secondsAgo(int seconds) { + return '$secondsс назад'; + } + + @override + String get sending => 'Отправка...'; + + @override + String get sent => 'Отправлено'; + + @override + String get delivered => 'Доставлено'; + + @override + String deliveredWithTime(int time) { + return 'Доставлено ($timeмс)'; + } + + @override + String get failed => 'Ошибка'; + + @override + String get broadcast => 'Трансляция'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Доставлено $delivered/$total контактам'; + } + + @override + String get allDelivered => 'Все доставлено'; + + @override + String get recipientDetails => 'Детали получателей'; + + @override + String get pending => 'Ожидание'; + + @override + String get sarMarkerFoundPerson => 'Найденный человек'; + + @override + String get sarMarkerFire => 'Место пожара'; + + @override + String get sarMarkerStagingArea => 'Место сбора'; + + @override + String get sarMarkerObject => 'Найденный объект'; + + @override + String get from => 'От'; + + @override + String get coordinates => 'Координаты'; + + @override + String get tapToViewOnMap => 'Нажмите, чтобы открыть на карте'; + + @override + String get radioSettings => 'Настройки радио'; + + @override + String get frequencyMHz => 'Частота (МГц)'; + + @override + String get bandwidth => 'Полоса пропускания'; + + @override + String get spreadingFactor => 'Коэффициент расширения'; + + @override + String get codingRate => 'Скорость кодирования'; + + @override + String get txPowerDbm => 'Мощность TX (дБм)'; + + @override + String maxPowerDbm(int power) { + return 'Макс: $power дБм'; + } + + @override + String get you => 'Вы'; + + @override + String exportFailed(String error) { + return 'Ошибка экспорта: $error'; + } + + @override + String importFailed(String error) { + return 'Ошибка импорта: $error'; + } + + @override + String get unknown => 'Неизвестно'; + + @override + String get onlineLayers => 'Онлайн-слои'; + + @override + String get locationTrail => 'Трек местоположения'; + + @override + String get showTrailOnMap => 'Показать трек на карте'; + + @override + String get trailVisible => 'Трек отображается на карте'; + + @override + String get trailHiddenRecording => 'Трек скрыт (запись продолжается)'; + + @override + String get duration => 'Продолжительность'; + + @override + String get points => 'Точки'; + + @override + String get clearTrail => 'Очистить трек'; + + @override + String get clearTrailQuestion => 'Очистить трек?'; + + @override + String get clearTrailConfirmation => + 'Вы уверены, что хотите очистить текущий трек? Это действие нельзя отменить.'; + + @override + String get noTrailRecorded => 'Трек ещё не записан'; + + @override + String get startTrackingToRecord => + 'Запустите отслеживание геолокации для записи трека'; + + @override + String get trailControls => 'Управление треком'; + + @override + String get model => 'Модель'; + + @override + String get version => 'Версия'; + + @override + String get maxContacts => 'Макс. контактов'; + + @override + String get maxChannels => 'Макс. каналов'; + + @override + String get publicInfo => 'Публичная информация'; + + @override + String get useCurrentLocation => 'Использовать текущее местоположение'; + + @override + String get noneUnknown => 'Нет/Неизвестно'; + + @override + String get chatNode => 'Узел чата'; + + @override + String get repeater => 'Ретранслятор'; + + @override + String get roomChannel => 'Комната/Канал'; + + @override + String typeNumber(int number) { + return 'Тип $number'; + } + + @override + String failedToSave(String error) { + return 'Не удалось сохранить: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Не удалось получить местоположение: $error'; + } + + @override + String get sarTemplates => 'Шаблоны SAR'; + + @override + String get addTemplate => 'Добавить шаблон'; + + @override + String get editTemplate => 'Изменить шаблон'; + + @override + String get deleteTemplate => 'Удалить шаблон'; + + @override + String get templateName => 'Название шаблона'; + + @override + String get templateNameHint => 'например, Найденный человек'; + + @override + String get templateEmoji => 'Эмодзи'; + + @override + String get emojiRequired => 'Требуется эмодзи'; + + @override + String get nameRequired => 'Требуется название'; + + @override + String get templateDescription => 'Описание (необязательно)'; + + @override + String get templateDescriptionHint => 'Добавьте дополнительный контекст...'; + + @override + String get templateColor => 'Цвет'; + + @override + String get previewFormat => 'Предпросмотр (формат сообщения SAR)'; + + @override + String get importFromClipboard => 'Импорт'; + + @override + String get exportToClipboard => 'Экспорт'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Удалить шаблон \'$name\'?'; + } + + @override + String get templateAdded => 'Шаблон добавлен'; + + @override + String get templateUpdated => 'Шаблон обновлён'; + + @override + String get templateDeleted => 'Шаблон удалён'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Импортировано $count шаблонов', + one: 'Импортирован 1 шаблон', + zero: 'Шаблоны не импортированы', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count шаблонов экспортировано в буфер обмена', + one: '1 шаблон экспортирован в буфер обмена', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Сбросить до умолчаний'; + + @override + String get resetToDefaultsConfirmation => + 'Все пользовательские шаблоны будут удалены и восстановлены 4 шаблона по умолчанию. Продолжить?'; + + @override + String get reset => 'Сбросить'; + + @override + String get resetComplete => 'Шаблоны сброшены до умолчаний'; + + @override + String get noTemplates => 'Шаблоны недоступны'; + + @override + String get tapAddToCreate => 'Нажмите +, чтобы создать первый шаблон'; + + @override + String get ok => 'ОК'; + + @override + String get locationPermission => 'Разрешение геолокации'; + + @override + String get checking => 'Проверка...'; + + @override + String get locationPermissionGrantedAlways => 'Разрешено (всегда)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Разрешено (при использовании)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Запрещено — нажмите для запроса'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Запрещено навсегда — откройте настройки'; + + @override + String get locationPermissionDialogContent => + 'Доступ к геолокации запрещён навсегда. Включите его в настройках устройства для GPS-трекинга и обмена местоположением.'; + + @override + String get openSettings => 'Открыть настройки'; + + @override + String get locationPermissionGranted => 'Разрешение геолокации получено!'; + + @override + String get locationPermissionRequiredForGps => + 'Разрешение геолокации необходимо для GPS-трекинга и обмена местоположением.'; + + @override + String get locationPermissionAlreadyGranted => + 'Разрешение геолокации уже предоставлено.'; + + @override + String get sarNavyBlue => 'SAR Тёмно-синяя'; + + @override + String get sarNavyBlueDescription => 'Профессиональный / оперативный режим'; + + @override + String get selectRecipient => 'Выбрать получателя'; + + @override + String get broadcastToAllNearby => 'Трансляция всем ближайшим'; + + @override + String get searchRecipients => 'Поиск получателей...'; + + @override + String get noContactsFound => 'Контакты не найдены'; + + @override + String get noRoomsFound => 'Комнаты не найдены'; + + @override + String get noRecipientsAvailable => 'Нет доступных получателей'; + + @override + String get noChannelsFound => 'Каналы не найдены'; + + @override + String get newMessage => 'Новое сообщение'; + + @override + String get channel => 'Канал'; + + @override + String get samplePoliceLead => 'Руководитель группы полиции'; + + @override + String get sampleDroneOperator => 'Оператор дрона'; + + @override + String get sampleFirefighterAlpha => 'Пожарный'; + + @override + String get sampleMedicCharlie => 'Медик'; + + @override + String get sampleCommandDelta => 'Командование'; + + @override + String get sampleFireEngine => 'Пожарная машина'; + + @override + String get sampleAirSupport => 'Авиационная поддержка'; + + @override + String get sampleBaseCoordinator => 'Базовый координатор'; + + @override + String get channelEmergency => 'Аварийный'; + + @override + String get channelCoordination => 'Координация'; + + @override + String get channelUpdates => 'Обновления'; + + @override + String get sampleTeamMember => 'Тестовый член команды'; + + @override + String get sampleScout => 'Тестовый разведчик'; + + @override + String get sampleBase => 'Тестовая база'; + + @override + String get sampleSearcher => 'Тестовый поисковик'; + + @override + String get sampleObjectBackpack => ' Найден рюкзак синего цвета'; + + @override + String get sampleObjectVehicle => + ' Брошенный автомобиль — установить владельца'; + + @override + String get sampleObjectCamping => ' Обнаружено туристическое снаряжение'; + + @override + String get sampleObjectTrailMarker => ' Указатель тропы найден вне маршрута'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Всем группам: сообщить о готовности'; + + @override + String get sampleMsgWeatherUpdate => 'Погода: ясно, температура 18°C'; + + @override + String get sampleMsgBaseCamp => 'Базовый лагерь развёрнут у места сбора'; + + @override + String get sampleMsgTeamAlpha => 'Группа выдвигается в сектор 2'; + + @override + String get sampleMsgRadioCheck => 'Проверка связи — всем станциям ответить'; + + @override + String get sampleMsgWaterSupply => 'Вода доступна на контрольной точке 3'; + + @override + String get sampleMsgTeamBravo => 'Группа докладывает: сектор 1 чист'; + + @override + String get sampleMsgEtaRallyPoint => 'Прибытие на место сбора: 15 минут'; + + @override + String get sampleMsgSupplyDrop => 'Сброс снаряжения подтверждён на 14:00'; + + @override + String get sampleMsgDroneSurvey => + 'Разведка дроном завершена — ничего не обнаружено'; + + @override + String get sampleMsgTeamCharlie => 'Группа запрашивает подкрепление'; + + @override + String get sampleMsgRadioDiscipline => + 'Всем подразделениям: соблюдать радиодисциплину'; + + @override + String get sampleMsgUrgentMedical => + 'СРОЧНО: нужна медицинская помощь в секторе 4'; + + @override + String get sampleMsgAdultMale => ' Взрослый мужчина, в сознании'; + + @override + String get sampleMsgFireSpotted => 'Обнаружен пожар — координаты следуют'; + + @override + String get sampleMsgSpreadingRapidly => ' Распространяется быстро!'; + + @override + String get sampleMsgPriorityHelicopter => + 'ПРИОРИТЕТ: нужна поддержка вертолёта'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Медицинская группа движется к вашему местоположению'; + + @override + String get sampleMsgEvacHelicopter => + 'Вертолёт эвакуации: прибытие через 10 минут'; + + @override + String get sampleMsgEmergencyResolved => + 'Чрезвычайная ситуация ликвидирована — опасности нет'; + + @override + String get sampleMsgEmergencyStagingArea => ' Аварийное место сбора'; + + @override + String get sampleMsgEmergencyServices => + 'Аварийные службы уведомлены и реагируют'; + + @override + String get sampleAlphaTeamLead => 'Руководитель группы'; + + @override + String get sampleBravoScout => 'Разведчик'; + + @override + String get sampleCharlieMedic => 'Медик'; + + @override + String get sampleDeltaNavigator => 'Навигатор'; + + @override + String get sampleEchoSupport => 'Поддержка'; + + @override + String get sampleBaseCommand => 'Базовое командование'; + + @override + String get sampleFieldCoordinator => 'Полевой координатор'; + + @override + String get sampleMedicalTeam => 'Медицинская группа'; + + @override + String get mapDrawing => 'Рисунок на карте'; + + @override + String get navigateToDrawing => 'Перейти к рисунку'; + + @override + String get copyCoordinates => 'Копировать координаты'; + + @override + String get hideFromMap => 'Скрыть с карты'; + + @override + String get lineDrawing => 'Линия'; + + @override + String get rectangleDrawing => 'Прямоугольник'; + + @override + String get manualCoordinates => 'Ввод координат вручную'; + + @override + String get enterCoordinatesManually => 'Ввести координаты вручную'; + + @override + String get latitudeLabel => 'Широта'; + + @override + String get longitudeLabel => 'Долгота'; + + @override + String get exampleCoordinates => 'Пример: 55.7558, 37.6173'; + + @override + String get shareDrawing => 'Поделиться рисунком'; + + @override + String get shareWithAllNearbyDevices => + 'Поделиться со всеми ближайшими устройствами'; + + @override + String get shareToRoom => 'Отправить в комнату'; + + @override + String get sendToPersistentStorage => + 'Отправить в постоянное хранилище комнаты'; + + @override + String get deleteDrawingConfirm => + 'Вы уверены, что хотите удалить этот рисунок?'; + + @override + String get drawingDeleted => 'Рисунок удалён'; + + @override + String yourDrawingsCount(int count) { + return 'Ваши рисунки ($count)'; + } + + @override + String get shared => 'Передано'; + + @override + String get line => 'Линия'; + + @override + String get rectangle => 'Прямоугольник'; + + @override + String get updateAvailable => 'Доступно обновление'; + + @override + String get currentVersion => 'Текущая'; + + @override + String get latestVersion => 'Последняя'; + + @override + String get downloadUpdate => 'Скачать'; + + @override + String get updateLater => 'Позже'; + + @override + String get cadastralParcels => 'Кадастровые участки'; + + @override + String get forestRoads => 'Лесные дороги'; + + @override + String get wmsOverlays => 'WMS-наложения'; + + @override + String get hikingTrails => 'Туристические маршруты'; + + @override + String get mainRoads => 'Главные дороги'; + + @override + String get houseNumbers => 'Номера домов'; + + @override + String get fireHazardZones => 'Пожароопасные зоны'; + + @override + String get historicalFires => 'Исторические пожары'; + + @override + String get firebreaks => 'Противопожарные просеки'; + + @override + String get krasFireZones => 'Пожарные зоны Краса'; + + @override + String get placeNames => 'Названия мест'; + + @override + String get municipalityBorders => 'Границы муниципалитетов'; + + @override + String get topographicMap => 'Топографическая карта 1:25000'; + + @override + String get recentMessages => 'Последние сообщения'; + + @override + String get addChannel => 'Добавить канал'; + + @override + String get channelName => 'Название канала'; + + @override + String get channelNameHint => 'например, Группа спасения Альфа'; + + @override + String get channelSecret => 'Секрет канала'; + + @override + String get channelSecretHint => 'Общий пароль для этого канала'; + + @override + String get channelSecretHelp => + 'Этот секрет необходимо передать всем членам команды, которым нужен доступ к каналу'; + + @override + String get channelTypesInfo => + 'Hash-каналы (#команда): секрет генерируется из названия автоматически. Одно название = один канал на всех устройствах.\n\nЗакрытые каналы: используется явный секрет. Войти могут только те, у кого есть секрет.'; + + @override + String get hashChannelInfo => + 'Hash-канал: секрет будет автоматически создан из названия. Все, кто использует одно и то же имя, окажутся в одном канале.'; + + @override + String get channelNameRequired => 'Требуется название канала'; + + @override + String get channelNameTooLong => + 'Название канала не должно превышать 31 символ'; + + @override + String get channelSecretRequired => 'Требуется секрет канала'; + + @override + String get channelSecretTooLong => + 'Секрет канала не должен превышать 32 символа'; + + @override + String get invalidAsciiCharacters => 'Разрешены только символы ASCII'; + + @override + String get channelCreatedSuccessfully => 'Канал успешно создан'; + + @override + String channelCreationFailed(String error) { + return 'Не удалось создать канал: $error'; + } + + @override + String get deleteChannel => 'Удалить канал'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Вы уверены, что хотите удалить канал \"$channelName\"? Это действие нельзя отменить.'; + } + + @override + String get channelDeletedSuccessfully => 'Канал успешно удалён'; + + @override + String channelDeletionFailed(String error) { + return 'Не удалось удалить канал: $error'; + } + + @override + String get createChannel => 'Создать канал'; + + @override + String get wizardBack => 'Назад'; + + @override + String get wizardSkip => 'Пропустить'; + + @override + String get wizardNext => 'Далее'; + + @override + String get wizardGetStarted => 'Начать'; + + @override + String get wizardWelcomeTitle => 'Добро пожаловать в MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Просмотреть обучение'; + + @override + String get allTeamContacts => 'Все контакты команды'; + + @override + String directMessagesInfo(int count) { + return 'Личные сообщения с подтверждением. Отправлено $count членам команды.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'Маркер SAR отправлен $count контактам'; + } + + @override + String get noContactsAvailable => 'Нет доступных контактов команды'; + + @override + String get reply => 'Ответить'; + + @override + String get technicalDetails => 'Технические детали'; + + @override + String get messageTechnicalDetails => 'Технические детали сообщения'; + + @override + String get linkQuality => 'Качество связи'; + + @override + String get delivery => 'Доставка'; + + @override + String get status => 'Статус'; + + @override + String get expectedAckTag => 'Ожидаемый тег ACK'; + + @override + String get roundTrip => 'Время отклика'; + + @override + String get retryAttempt => 'Попытка повтора'; + + @override + String get floodFallback => 'Широковещательный резерв'; + + @override + String get identity => 'Идентификатор'; + + @override + String get messageId => 'ID сообщения'; + + @override + String get sender => 'Отправитель'; + + @override + String get senderKey => 'Ключ отправителя'; + + @override + String get recipient => 'Получатель'; + + @override + String get recipientKey => 'Ключ получателя'; + + @override + String get voice => 'Голос'; + + @override + String get voiceId => 'ID голоса'; + + @override + String get envelope => 'Конверт'; + + @override + String get sessionProgress => 'Прогресс сессии'; + + @override + String get complete => 'Завершено'; + + @override + String get rawDump => 'Необработанные данные'; + + @override + String get cannotRetryMissingRecipient => + 'Повтор невозможен: нет информации о получателе'; + + @override + String get voiceUnavailable => 'Голос сейчас недоступен'; + + @override + String get requestingVoice => 'Запрос голоса'; + + @override + String get device => 'устройство'; + + @override + String get change => 'Изменить'; + + @override + String get wizardOverviewDescription => + 'Это приложение объединяет сообщения MeshCore, полевые обновления SAR, карты и инструменты устройства в одном месте.'; + + @override + String get wizardOverviewFeature1 => + 'Отправляйте личные сообщения, сообщения комнат и сообщения каналов из основной вкладки «Сообщения».'; + + @override + String get wizardOverviewFeature2 => + 'Делитесь SAR-маркерами, рисунками на карте, голосовыми клипами и изображениями через mesh-сеть.'; + + @override + String get wizardOverviewFeature3 => + 'Подключайтесь по BLE или TCP, а затем управляйте сопутствующей радиостанцией прямо из приложения.'; + + @override + String get wizardMessagingTitle => 'Сообщения и полевые отчёты'; + + @override + String get wizardMessagingDescription => + 'Здесь сообщения это больше, чем просто текст. Приложение уже поддерживает несколько видов операционных данных и сценариев передачи.'; + + @override + String get wizardMessagingFeature1 => + 'Отправляйте личные сообщения, сообщения комнат и трафик каналов из одного редактора.'; + + @override + String get wizardMessagingFeature2 => + 'Создавайте обновления SAR и повторно используемые шаблоны SAR для типовых полевых отчётов.'; + + @override + String get wizardMessagingFeature3 => + 'Передавайте голосовые сессии и изображения с индикатором прогресса и оценкой эфирного времени в интерфейсе.'; + + @override + String get wizardConnectDeviceTitle => 'Подключить устройство'; + + @override + String get wizardConnectDeviceDescription => + 'Подключите свою радиостанцию MeshCore, выберите имя и примените радиопрофиль перед продолжением.'; + + @override + String get wizardSetupBadge => 'Настройка'; + + @override + String get wizardOverviewBadge => 'Обзор'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Подключено к $deviceName'; + } + + @override + String get wizardNoDeviceConnected => 'Устройство ещё не подключено'; + + @override + String get wizardSkipForNow => 'Пропустить пока'; + + @override + String get wizardDeviceNameLabel => 'Имя устройства'; + + @override + String get wizardDeviceNameHelp => + 'Это имя транслируется другим пользователям MeshCore.'; + + @override + String get wizardConfigRegionLabel => 'Регион конфигурации'; + + @override + String get wizardConfigRegionHelp => + 'Используется полный официальный список профилей MeshCore. По умолчанию выбран EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Убедитесь, что выбранный профиль соответствует местным радиоправилам.'; + + @override + String get wizardPresetNote2 => + 'Список соответствует официальному потоку профилей инструмента MeshCore config.'; + + @override + String get wizardPresetNote3 => + 'Для онбординга по умолчанию остаётся выбран EU/UK (Narrow).'; + + @override + String get wizardSaving => 'Сохранение...'; + + @override + String get wizardSaveAndContinue => 'Сохранить и продолжить'; + + @override + String get wizardEnterDeviceName => + 'Введите имя устройства перед продолжением.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return 'Сохранено: $deviceName, профиль $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Контакты, комнаты и ретрансляторы'; + + @override + String get wizardNetworkDescription => + 'Вкладка «Контакты» организует сеть, которую вы обнаруживаете, и маршруты, которые вы узнаёте со временем.'; + + @override + String get wizardNetworkFeature1 => + 'Просматривайте участников команды, ретрансляторы, комнаты, каналы и ожидающие объявления в одном списке.'; + + @override + String get wizardNetworkFeature2 => + 'Используйте smart ping, вход в комнаты, изученные пути и инструменты сброса маршрутов, когда связь становится нестабильной.'; + + @override + String get wizardNetworkFeature3 => + 'Создавайте каналы и управляйте сетевыми назначениями, не покидая приложение.'; + + @override + String get wizardMapOpsTitle => 'Карта, следы и общая геометрия'; + + @override + String get wizardMapOpsDescription => + 'Карта приложения напрямую связана с сообщениями, отслеживанием и SAR-слоями, а не является отдельным просмотрщиком.'; + + @override + String get wizardMapOpsFeature1 => + 'Отслеживайте свою позицию, местоположение команды и траектории движения на карте.'; + + @override + String get wizardMapOpsFeature2 => + 'Открывайте рисунки из сообщений, просматривайте их прямо в интерфейсе и удаляйте с карты при необходимости.'; + + @override + String get wizardMapOpsFeature3 => + 'Используйте карты ретрансляторов и общие слои, чтобы понимать покрытие сети в поле.'; + + @override + String get wizardToolsTitle => 'Инструменты помимо сообщений'; + + @override + String get wizardToolsDescription => + 'Здесь есть больше, чем четыре основные вкладки. Приложение также включает настройку, диагностику и необязательные сценарии датчиков.'; + + @override + String get wizardToolsFeature1 => + 'Откройте настройки устройства, чтобы изменить параметры радио, телеметрию, мощность TX и сведения о сопутствующем устройстве.'; + + @override + String get wizardToolsFeature2 => + 'Включите вкладку «Датчики», если нужны панели наблюдаемых датчиков и быстрые действия обновления.'; + + @override + String get wizardToolsFeature3 => + 'Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.'; + + @override + String get postConnectDiscoveryTitle => 'Обнаружить ретрансляторы сейчас?'; + + @override + String get postConnectDiscoveryDescription => + 'Запустите обнаружение ретрансляторов сразу после подключения, чтобы увидеть ближайшие узлы MeshCore и быстрее добавить их в свою сеть.'; + + @override + String get contactInSensors => 'В датчиках'; + + @override + String get contactAddToSensors => 'Добавить в датчики'; + + @override + String get contactSetPath => 'Задать путь'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName добавлен в Датчики'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Не удалось очистить маршрут: $error'; + } + + @override + String get contactRouteCleared => 'Маршрут очищен'; + + @override + String contactRouteSet(String route) { + return 'Маршрут задан: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Не удалось задать маршрут: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'Таймаут ACK'; + + @override + String get opcode => 'Опкод'; + + @override + String get payload => 'Полезная нагрузка'; + + @override + String get hops => 'Хопы'; + + @override + String get hashSize => 'Размер хеша'; + + @override + String get pathBytes => 'Байты пути'; + + @override + String get selectedPath => 'Выбранный путь'; + + @override + String get estimatedTx => 'Ожидаемая передача'; + + @override + String get senderToReceipt => 'От отправителя до получения'; + + @override + String get receivedCopies => 'Полученные копии'; + + @override + String get retryCause => 'Причина повтора'; + + @override + String get retryMode => 'Режим повтора'; + + @override + String get retryResult => 'Результат повтора'; + + @override + String get lastRetry => 'Последний повтор'; + + @override + String get rxPackets => 'RX-пакеты'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Скорость'; + + @override + String get window => 'Окно'; + + @override + String get posttxDelay => 'Задержка после передачи'; + + @override + String get bandpass => 'Полосовой'; + + @override + String get bandpassFilterVoice => 'Полосовой фильтр голоса'; + + @override + String get active => 'Активно'; + + @override + String get addContact => 'Добавить контакт'; + + @override + String get all => 'Все'; + + @override + String get clearAllLabel => 'Очистить все'; + + @override + String get clearFilters => 'Очистить фильтры'; + + @override + String get clearRoute => 'Очистить маршрут'; + + @override + String get clearMessages => 'Очистить сообщения'; + + @override + String get clearScale => 'Очистить масштаб'; + + @override + String get clearDiscoveries => 'Очистить обнаружения'; + + @override + String get clearOnlineTraceDatabase => 'Очистить базу трассировок'; + + @override + String get clearAllChannels => 'Очистить все каналы'; + + @override + String get clearAllContacts => 'Очистить все контакты'; + + @override + String get clearChannels => 'Очистить каналы'; + + @override + String get clearContacts => 'Очистить контакты'; + + @override + String get clearPathOnMaxRetry => 'Очистить путь при макс. повторе'; + + @override + String get create => 'Создать'; + + @override + String get custom => 'Пользовательский'; + + @override + String get defaultValue => 'По умолчанию'; + + @override + String get duplicate => 'Дублировать'; + + @override + String get editName => 'Редактировать имя'; + + @override + String get open => 'Открыть'; + + @override + String get paste => 'Вставить'; + + @override + String get preview => 'Предпросмотр'; + + @override + String get remove => 'Удалить'; + + @override + String get rename => 'Переименовать'; + + @override + String get resolveAll => 'Разрешить все'; + + @override + String get send => 'Отправить'; + + @override + String get sendAnyway => 'Отправить все равно'; + + @override + String get share => 'Поделиться'; + + @override + String get trace => 'Трассировка'; + + @override + String get discovery => 'Обнаружение'; + + @override + String get discoverRepeaters => 'Обнаружить повторители'; + + @override + String get discoverSensors => 'Обнаружить датчики'; + + @override + String get repeaterDiscoverySent => 'Обнаружение повторителей отправлено'; + + @override + String get sensorDiscoverySent => 'Обнаружение датчиков отправлено'; + + @override + String get clearedPendingDiscoveries => 'Ожидающие обнаружения очищены.'; + + @override + String get autoDiscovery => 'Автоматическое обнаружение'; + + @override + String get enableAutomaticAdding => 'Включить автоматическое добавление'; + + @override + String get autoaddRepeaters => 'Автодобавление повторителей'; + + @override + String get autoaddRoomServers => 'Автодобавление серверов комнат'; + + @override + String get autoaddSensors => 'Автодобавление датчиков'; + + @override + String get autoaddUsers => 'Автодобавление пользователей'; + + @override + String get overwriteOldestWhenFull => 'Перезаписать старейшие при заполнении'; + + @override + String get storage => 'Хранилище'; + + @override + String get dangerZone => 'Опасная зона'; + + @override + String get profiles => 'Профили'; + + @override + String get favourites => 'Избранное'; + + @override + String get sensors => 'Датчики'; + + @override + String get others => 'Другие'; + + @override + String get gpsModule => 'Модуль GPS'; + + @override + String get liveTraffic => 'Живой трафик'; + + @override + String get repeatersMap => 'Карта повторителей'; + + @override + String get blePacketLogs => 'Журналы BLE-пакетов'; + + @override + String get onlineTraceDatabase => 'База трассировок'; + + @override + String get routePathByteSize => 'Размер пути в байтах'; + + @override + String get messageNotifications => 'Уведомления о сообщениях'; + + @override + String get sarAlerts => 'SAR-оповещения'; + + @override + String get discoveryNotifications => 'Уведомления об обнаружении'; + + @override + String get updateNotifications => 'Уведомления об обновлениях'; + + @override + String get muteWhileAppIsOpen => 'Без звука при открытом приложении'; + + @override + String get disableContacts => 'Отключить контакты'; + + @override + String get enableSensorsTab => 'Включить вкладку Датчики'; + + @override + String get enableProfiles => 'Включить профили'; + + @override + String get nearestRepeaterFallback => 'Ближайший повторитель как резерв'; + + @override + String get deleteAllStoredMessageHistory => 'Удалить всю историю сообщений'; + + @override + String get messageFontSize => 'Размер шрифта сообщений'; + + @override + String get rotateMapWithHeading => 'Вращать карту по направлению'; + + @override + String get showMapDebugInfo => 'Показать отладочную информацию карты'; + + @override + String get openMapInFullscreen => 'Открыть карту на весь экран'; + + @override + String get showSarMarkersLabel => 'Показать SAR-маркеры'; + + @override + String get hideRepeatersOnMap => 'Скрыть повторители на карте'; + + @override + String get setMapScale => 'Установить масштаб карты'; + + @override + String get customMapScaleSaved => 'Пользовательский масштаб карты сохранён'; + + @override + String get voiceBitrate => 'Битрейт голоса'; + + @override + String get voiceCompressor => 'Компрессор голоса'; + + @override + String get voiceLimiter => 'Лимитер голоса'; + + @override + String get micAutoGain => 'Автоусиление микрофона'; + + @override + String get echoCancellation => 'Подавление эха'; + + @override + String get noiseSuppression => 'Подавление шума'; + + @override + String get trimSilenceInVoiceMessages => + 'Обрезать тишину в голосовых сообщениях'; + + @override + String get compressor => 'Компрессор'; + + @override + String get limiter => 'Лимитер'; + + @override + String get autoGain => 'Автоусиление'; + + @override + String get echoCancel => 'Эхо'; + + @override + String get noiseSuppress => 'Шум'; + + @override + String get silenceTrim => 'Тишина'; + + @override + String get maxImageSize => 'Максимальный размер изображения'; + + @override + String get imageCompression => 'Сжатие изображения'; + + @override + String get grayscale => 'Оттенки серого'; + + @override + String get ultraMode => 'Режим ультра'; + + @override + String get fastPrivateGpsUpdates => 'Быстрые приватные GPS-обновления'; + + @override + String get movementThreshold => 'Порог движения'; + + @override + String get fastGpsMovementThreshold => 'Порог движения быстрого GPS'; + + @override + String get fastGpsActiveuseInterval => + 'Интервал активного использования быстрого GPS'; + + @override + String get activeuseUpdateInterval => + 'Интервал обновления при активном использовании'; + + @override + String get repeatNearbyTraffic => 'Повторять ближний трафик'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Ретранслировать через повторители по сети'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Только рядом, без flood повторителей'; + + @override + String get multihop => 'Многохоповый'; + + @override + String get createProfile => 'Создать профиль'; + + @override + String get renameProfile => 'Переименовать профиль'; + + @override + String get newProfile => 'Новый профиль'; + + @override + String get manageProfiles => 'Управление профилями'; + + @override + String get enableProfilesToStartManagingThem => + 'Включите профили, чтобы начать управлять ими.'; + + @override + String get openMessage => 'Открыть сообщение'; + + @override + String get jumpToTheRelatedSarMessage => 'Перейти к связанному SAR-сообщению'; + + @override + String get removeSarMarker => 'Удалить SAR-маркер'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Выберите назначение для отправки SAR-маркера'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'SAR-маркер отправлен в публичный канал'; + + @override + String get sarMarkerSentToRoom => 'SAR-маркер отправлен в комнату'; + + @override + String get loadFromGallery => 'Загрузить из галереи'; + + @override + String get replaceImage => 'Заменить изображение'; + + @override + String get selectFromGallery => 'Выбрать из галереи'; + + @override + String get team => 'Команда'; + + @override + String get found => 'Найдено'; + + @override + String get staging => 'Место сбора'; + + @override + String get object => 'Объект'; + + @override + String get quiet => 'Тихо'; + + @override + String get busy => 'Занято'; + + @override + String get searchMessages => 'Поиск сообщений'; + + @override + String get sendImageFromGallery => 'Отправить изображение из галереи'; + + @override + String get takePhoto => 'Сделать фото'; + + @override + String get allMessages => 'Все сообщения'; + + @override + String get sendToPublicChannel => 'Отправить в публичный канал?'; + + @override + String get selectMarkerTypeAndDestination => + 'Выберите тип маркера и назначение'; + + @override + String get noDestinationsAvailableLabel => 'Нет доступных назначений'; + + @override + String get image => 'Изображение'; + + @override + String get format => 'Формат'; + + @override + String get dimensions => 'Размеры'; + + @override + String get segments => 'Сегменты'; + + @override + String get transfers => 'Передачи'; + + @override + String get downloadedBy => 'Загружено'; + + @override + String get saveDiscoverySettings => 'Сохранить настройки обнаружения'; + + @override + String get savePublicInfo => 'Сохранить публичную информацию'; + + @override + String get saveRadioSettings => 'Сохранить настройки радио'; + + @override + String get savePath => 'Сохранить путь'; + + @override + String get wipeDeviceData => 'Стереть данные устройства'; + + @override + String get wipeDevice => 'Стереть устройство'; + + @override + String get destructiveDeviceActions => 'Деструктивные действия устройства.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Выберите пресет или настройте радио вручную.'; + + @override + String get used => 'Использовано'; + + @override + String get total => 'Всего'; + + @override + String get renameValue => 'Переименовать значение'; + + @override + String get customizeFields => 'Настроить поля'; + + @override + String get livePreview => 'Предпросмотр в реальном времени'; + + @override + String get refreshSchedule => 'Расписание обновления'; + + @override + String get noResponse => 'Нет ответа'; + + @override + String get refreshing => 'Обновление'; + + @override + String get unavailable => 'Недоступно'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Выберите реле или узел для наблюдения.'; + + @override + String get publicKeyLabel => 'Публичный ключ'; + + @override + String get alreadyInContacts => 'Уже в контактах'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Подключитесь к устройству перед добавлением контактов'; + + @override + String get fromContacts => 'Из контактов'; + + @override + String get onlineOnly => 'Только онлайн'; + + @override + String get inBoth => 'В обоих'; + + @override + String get source => 'Источник'; + + @override + String get allMessagesCleared => 'Все сообщения очищены'; + + @override + String get onlineTraceDatabaseCleared => 'База трассировок очищена'; + + @override + String get packetLogsCleared => 'Журналы пакетов очищены'; + + @override + String get hexDataCopiedToClipboard => 'Hex-данные скопированы в буфер'; + + @override + String get developerModeEnabled => 'Режим разработчика включён'; + + @override + String get developerModeDisabled => 'Режим разработчика отключён'; + + @override + String get clipboardIsEmpty => 'Буфер обмена пуст'; + + @override + String get contactImported => 'Контакт импортирован'; + + @override + String get contactLinkCopiedToClipboard => + 'Ссылка на контакт скопирована в буфер'; + + @override + String get failedToExportContact => 'Не удалось экспортировать контакт'; + + @override + String get noLogsToExport => 'Нет журналов для экспорта'; + + @override + String get exportAsCsv => 'Экспортировать как CSV'; + + @override + String get exportAsText => 'Экспортировать как текст'; + + @override + String get receivedRfc3339 => 'Получено (RFC3339)'; + + @override + String get buildTime => 'Время сборки'; + + @override + String get downloadUrlNotAvailable => 'URL загрузки недоступен'; + + @override + String get cannotOpenDownloadUrl => 'Не удаётся открыть URL загрузки'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'Проверка обновлений доступна только на Android'; + + @override + String get youAreRunningTheLatestVersion => 'Вы используете последнюю версию'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Обновление доступно, но URL загрузки не найден'; + + @override + String get startTictactoe => 'Начать Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe недоступно'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: противник неизвестен'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: ожидание начала'; + + @override + String get acceptsShareLinks => 'Принимает общие ссылки'; + + @override + String get supportsRawHex => 'Поддерживает raw hex'; + + @override + String get clipboardfriendly => 'Удобно для буфера'; + + @override + String get captured => 'Захвачено'; + + @override + String get size => 'Размер'; + + @override + String get noCustomChannelsToClear => + 'Нет пользовательских каналов для очистки.'; + + @override + String get noDeviceContactsToClear => 'Нет контактов устройства для очистки.'; + + @override + String get aToZ => 'А-Я'; + + @override + String get add => 'Добавить'; + + @override + String get addAdditionalDetails => 'Добавить дополнительные сведения'; + + @override + String get addPeer => 'Добавить узел'; + + @override + String get addPeerManually => 'Добавить узел вручную'; + + @override + String addedToContacts(String name) { + return '$name добавлен в контакты'; + } + + @override + String addedToSensors(String name) { + return '$name добавлен в Датчики'; + } + + @override + String get allowAll => 'Разрешить все'; + + @override + String get anonymousRxStats => 'Анонимная статистика RX'; + + @override + String get appearance => 'Внешний вид'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Базовая телеметрия'; + + @override + String get blePin => 'BLE PIN'; + + @override + String get buzzerAlerts => 'Звуковые оповещения'; + + @override + String get cancelImageReceive => 'Отменить получение изображения'; + + @override + String get choose => 'Выбрать'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Выберите отображаемые вкладки и разделы контактов'; + + @override + String get clearCache => 'Очистить кэш'; + + @override + String get clearHistory => 'Очистить историю'; + + @override + String get clearLiveView => 'Очистить режим реального времени'; + + @override + String get clearLogs => 'Очистить журналы'; + + @override + String get clearOfflineCache => 'Очистить офлайн-кэш?'; + + @override + String get clearOverlay => 'Очистить наложение'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Очищать маршрут только после всех неудачных повторов'; + + @override + String get codecBitrateAndAudioProcessing => + 'Кодек, битрейт и обработка звука'; + + @override + String get connectByIpAddress => 'Подключиться по IP-адресу'; + + @override + String get controlWhichAlertsYouReceive => + 'Управление получаемыми оповещениями'; + + @override + String get copyFullHex => 'Копировать полный Hex'; + + @override + String get copyRawResponse => 'Копировать необработанный ответ'; + + @override + String get couldNotLoadMetHistory => 'Не удалось загрузить историю MET'; + + @override + String customizeSensor(String name) { + return 'Настроить $name'; + } + + @override + String get data => 'Данные'; + + @override + String get deleteGroup => 'Удалить группу'; + + @override + String deleteStyleConfirm(String name) { + return 'Удалить $name?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Удалить этот $type?'; + } + + @override + String get deny => 'Запретить'; + + @override + String get deviceInfo => 'Информация об устройстве'; + + @override + String get deviceSettings => 'Настройки устройства'; + + @override + String get deviceTimeSynced => + 'Время устройства синхронизировано с телефоном.'; + + @override + String get disableFastGpsPublishing => 'Отключить быструю публикацию GPS'; + + @override + String get displayMarkersAndTraceDatabase => + 'Отображение, маркеры и база трассировок'; + + @override + String get download => 'Скачать'; + + @override + String get environmentalTelemetry => 'Телеметрия окружающей среды'; + + @override + String errorCheckingForUpdates(String error) { + return 'Ошибка проверки обновлений: $error'; + } + + @override + String errorGeneric(String error) { + return 'Ошибка: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Ошибка открытия загрузки: $error'; + } + + @override + String get exportLogs => 'Экспортировать журналы'; + + @override + String get extraAggressiveAvifCompression => 'Сверхагрессивное сжатие AVIF'; + + @override + String failedToAddContact(String error) { + return 'Не удалось добавить контакт: $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Не удалось добавить $name: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Не удалось очистить каналы: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Не удалось очистить контакты: $error'; + } + + @override + String get failedToConnectViaSerial => + 'Не удалось подключиться через последовательный порт'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Не удалось загрузить изображение предпросмотра: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Не удалось загрузить трассировку: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Не удалось отправить маркер SAR: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Не удалось установить режим зуммера: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Не удалось установить режим GPS: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Не удалось синхронизировать время устройства: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Не удалось стереть данные устройства: $error'; + } + + @override + String get fastGpsTargetChannel => 'Целевой канал быстрого GPS'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Быстрые обновления, пороги и разрешения'; + + @override + String get fetchingCatalog => 'Загрузка каталога...'; + + @override + String get fifteenMinutes => '15 минут'; + + @override + String get fifteenSeconds => '15 секунд'; + + @override + String get filterByDirection => 'Фильтр по направлению'; + + @override + String get finish => 'Завершить'; + + @override + String get fiveMinutes => '5 минут'; + + @override + String get fiveSeconds => '5 секунд'; + + @override + String get fix => 'Фикс'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'Политика объявлений GPS'; + + @override + String get gpsAndLocation => 'GPS и местоположение'; + + @override + String get gpsDiagnostics => 'Диагностика GPS'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Помощь'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Скрыто'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Как радио автоматически добавляет обнаруженные узлы.'; + + @override + String get images => 'Изображения'; + + @override + String get importProfile => 'Импортировать профиль'; + + @override + String get lastFix => 'Последний фикс'; + + @override + String get latest => 'Последняя'; + + @override + String get latitude => 'Широта'; + + @override + String get loadASavedRegion => 'Загрузить сохранённый регион'; + + @override + String get loading => 'Загрузка'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Обмен местоположением, оборудование и интервал обновления.'; + + @override + String get locationTelemetry => 'Телеметрия местоположения'; + + @override + String get lockDestination => 'Зафиксировать назначение'; + + @override + String get longitude => 'Долгота'; + + @override + String get mapStyle => 'Стиль карты'; + + @override + String get max => 'Макс'; + + @override + String get maxZoom => 'Макс. масштаб'; + + @override + String get messaging => 'Сообщения'; + + @override + String get metHistory => 'История MET'; + + @override + String get min => 'Мин'; + + @override + String get minZoom => 'Мин. масштаб'; + + @override + String get moveDown => 'Переместить вниз'; + + @override + String get moveUp => 'Переместить вверх'; + + @override + String get multiAckMode => 'Режим множественного ACK'; + + @override + String get multiDeviceWorkspaceManagement => + 'Управление рабочим пространством на нескольких устройствах'; + + @override + String get myLocation => 'Моё местоположение'; + + @override + String get nameAndTelemetryShared => + 'Имя и телеметрия доступны другим устройствам.'; + + @override + String newVersionAvailable(String version) { + return 'Новая версия: $version'; + } + + @override + String get newer => 'Новее'; + + @override + String get noNeighboursFound => 'Соседи не найдены'; + + @override + String get noServersFound => 'Серверы не найдены'; + + @override + String get notSet => 'Не задано'; + + @override + String get notifications => 'Уведомления'; + + @override + String get offZeroSeconds => 'Выкл (0 с)'; + + @override + String get offlineMaps => 'Офлайн-карты'; + + @override + String get older => 'Старее'; + + @override + String get oneByteMode0 => '1 байт (режим 0)'; + + @override + String get oneHour => '1 час'; + + @override + String get oneMinute => '1 минута'; + + @override + String get openPacketLogs => 'Открыть журналы пакетов'; + + @override + String get packetTypeHelp => 'Справка по типам пакетов'; + + @override + String get pasteShareLinkOrHexAdvert => 'Вставьте ссылку или Hex-объявление'; + + @override + String get pathHash => 'Хеш пути'; + + @override + String get pathSize => 'Размер пути'; + + @override + String get ping => 'Пинг'; + + @override + String get polygon => 'Полигон'; + + @override + String get rawResponseCopied => 'Необработанный ответ скопирован'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Повторить через ближайший ретранслятор при ошибке'; + + @override + String get resolveContact => 'Разрешить контакт'; + + @override + String get routingRetriesAndDestinationLock => + 'Маршрутизация, повторы и фиксация назначения'; + + @override + String get samples => 'Образцы'; + + @override + String get satellites => 'Спутники'; + + @override + String get saving => 'Сохранение...'; + + @override + String get search => 'Поиск'; + + @override + String get searchByNameOrToken => 'Поиск по имени или токену'; + + @override + String get searchDiscoveredNodes => 'Поиск обнаруженных узлов'; + + @override + String get searchInCurrentFilter => 'Поиск в текущем фильтре'; + + @override + String get searchLogs => 'Поиск по журналам...'; + + @override + String get searchSensors => 'Поиск датчиков'; + + @override + String get searchThisSection => 'Поиск в этом разделе'; + + @override + String get selectedRelays => 'Выбранные ретрансляторы'; + + @override + String get sensor => 'Датчик'; + + @override + String serialError(String error) { + return 'Ошибка последовательного порта: $error'; + } + + @override + String setPathForContact(String name) { + return 'Задать путь для $name'; + } + + @override + String get shareLiveGps => 'Транслировать GPS'; + + @override + String get shareMyTiles => 'Поделиться моими тайлами'; + + @override + String get sharingLocationFromPhone => + 'Трансляция местоположения на этом канале с телефона.'; + + @override + String get sharingLocationFromRadio => + 'Трансляция местоположения на этом канале с радио.'; + + @override + String signalDbm(String rssi) { + return 'Сигнал $rssi дБм'; + } + + @override + String get sizeCompressionAndPreview => 'Размер, сжатие и предпросмотр'; + + @override + String get simpleMode => 'Простой режим'; + + @override + String get simpleModeDescription => + 'Только чат и карта с избранными контактами'; + + @override + String get simpleModeRequiresLockedDestination => + 'Сначала включите «Зафиксировать назначение» с каналом, чтобы использовать простой режим'; + + @override + String get noFavouriteContacts => 'Нет избранных контактов'; + + @override + String get skip => 'Пропустить'; + + @override + String get smallerFileSize => 'Меньший размер файла'; + + @override + String get sort => 'Сортировка'; + + @override + String get stoppedSharingLocation => + 'Трансляция местоположения на этом канале остановлена.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Отключить уведомления при открытом приложении'; + + @override + String get tabsAndNavigation => 'Вкладки и навигация'; + + @override + String get templatesAndTutorials => 'Шаблоны и обучение'; + + @override + String get tenMinutes => '10 минут'; + + @override + String get tenSeconds => '10 секунд'; + + @override + String get testSendUpdate => 'Тестовая отправка обновления'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Тема, язык и настройки отображения'; + + @override + String get thirtyMinutes => '30 минут'; + + @override + String get thirtySeconds => '30 секунд'; + + @override + String get threeBytesMode2 => '3 байта (режим 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Статистика трафика, история сообщений и инструменты разработчика'; + + @override + String get twoBytesMode1 => '2 байта (режим 1)'; + + @override + String get twoMinutes => '2 минуты'; + + @override + String get undo => 'Отменить'; + + @override + String get updated => 'Обновлено'; + + @override + String get useContactFlags => 'Использовать флаги контактов'; + + @override + String get useSavedCoordinates => 'Использовать сохранённые координаты'; + + @override + String get view => 'Просмотр'; + + @override + String get viewNeighbours => 'Просмотр соседей'; + + @override + String get viewPublicStats => 'Просмотр публичной статистики'; + + @override + String get setRegionScope => 'Установить область региона'; + + @override + String get regionScope => 'Область региона'; + + @override + String get regionScopeNone => 'Нет (глобально)'; + + @override + String get clearRegionScope => 'Очистить область'; + + @override + String get regionScopeWarning => + 'Только ретрансляторы, разрешающие этот регион, будут пересылать.'; + + @override + String get discoverRegions => 'Обнаружить из ретрансляторов'; + + @override + String get discoveringRegions => 'Обнаружение регионов...'; + + @override + String get enterRegionName => 'Название региона (напр. auckland)'; + + @override + String get noRegionsFound => 'Регионы не найдены на этом ретрансляторе.'; + + @override + String regionScopeSet(String name) { + return 'Область региона установлена на $name'; + } + + @override + String get regionScopeCleared => 'Область региона очищена'; + + @override + String get selectDestinationFirst => + 'Сначала выберите канал, контакт или комнату'; + + @override + String get recipientNoLongerAvailable => 'Получатель больше не доступен'; + + @override + String failedToSendMessage(String error) { + return 'Не удалось отправить: $error'; + } + + @override + String get tictactoeDirectOnly => + 'Крестики-нолики работают только в личных сообщениях. Сначала выберите контакт.'; + + @override + String get deviceKeyUnavailable => 'Ключ устройства недоступен'; + + @override + String get imageCompressionFailed => 'Не удалось сжать изображение'; + + @override + String get imageFragmentationFailed => + 'Не удалось разделить изображение на фрагменты'; + + @override + String get failedToAnnounceImage => 'Не удалось анонсировать изображение'; + + @override + String get noRecipientSelected => 'Получатель не выбран'; + + @override + String get imageSendFailed => 'Не удалось отправить изображение'; + + @override + String get microphonePermissionRequiredForVoice => + 'Для голосовых сообщений требуется разрешение на микрофон'; + + @override + String get recordingDiscarded => 'Запись отменена'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Вы собираетесь отправить $mediaType в публичный канал. Это не рекомендуется: получить это может любой в mesh-сети. Выберите приватный или тегированный канал, если только это не то, что вы хотите.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Выберите приватный канал, чтобы поделиться местоположением.'; + + @override + String get stopSharingMyLocation => 'Прекратить делиться местоположением'; + + @override + String get shareMyLocation => 'Поделиться местоположением'; + + @override + String get stopRecording => 'Остановить запись'; + + @override + String get recordVoice => 'Записать голосовое сообщение'; + + @override + String get moreActions => 'Другие действия'; + + @override + String get noMessagesInFilter => 'В этом фильтре нет сообщений'; + + @override + String get noMatchesInFilter => 'В этом фильтре нет совпадений'; + + @override + String syncFailed(String error) { + return 'Ошибка синхронизации: $error'; + } + + @override + String get invalidLink => 'Недействительная ссылка'; + + @override + String get cannotOpenLink => 'Не удаётся открыть ссылку'; + + @override + String get failedToOpenLink => 'Не удалось открыть ссылку'; + + @override + String get failedToResendMessage => 'Не удалось повторно отправить сообщение'; + + @override + String retryFailed(String error) { + return 'Повторная попытка не удалась: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Удалить рисунок и сообщение'; + + @override + String get lastEchoRelay => 'Последнее эхо: ретранслятор'; + + @override + String get lastEchoPath => 'Последнее эхо: путь'; + + @override + String get lastEchoBytesReport => 'Последнее эхо: отчёт о байтах'; + + @override + String get noGpsCoordinatesAvailable => 'Координаты GPS недоступны'; + + @override + String get notASarMarker => 'Это не маркер SAR'; + + @override + String failedToSaveTemplate(String error) { + return 'Не удалось сохранить шаблон: $error'; + } + + @override + String get drawingNotFound => 'Рисунок не найден'; + + @override + String get unknownDrawingType => 'Неизвестный тип рисунка'; + + @override + String get customMapMarker => 'Пользовательский маркер карты'; + + @override + String customMapPointLabel(String point) { + return 'Точка: $point'; + } + + @override + String mapIdLabel(String id) { + return 'ID карты: $id'; + } + + @override + String get cannotFetchVoice => 'Не удаётся получить голосовое сообщение'; + + @override + String get senderContactUnknown => + 'Контакт отправителя неизвестен. Сначала синхронизируйте контакты.'; + + @override + String get senderRouteUnknown => + 'Маршрут отправителя неизвестен. Сначала синхронизируйте контакты/путь.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Сообщение слишком далеко ($hops хопов, макс. $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'Маршрут отправителя не ответил на проверку пути. Синхронизируйте контакты/путь и попробуйте снова.'; + + @override + String get senderRouteNoRawResponse => + 'Маршрут отправителя не ответил на raw-транспортном пути.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Получение голоса через $hops хопов может занять время.'; + } + + @override + String get voiceReceiveCanceled => 'Приём голосового сообщения отменён'; + + @override + String get fetchingMissingVoiceFragments => + 'Получение недостающих фрагментов голоса'; + + @override + String get receivingVoice => 'Приём голоса'; + + @override + String get cannotFetchImage => 'Не удаётся получить изображение'; + + @override + String imageFetchOverHops(String hops) { + return 'Получение изображения через $hops хопов может занять время.'; + } + + @override + String get imageAlreadyBeingReceived => 'Изображение уже принимается'; + + @override + String get loadImage => 'Загрузить изображение'; + + @override + String get imageFetchFailedToSendRequest => + 'Не удалось отправить запрос на изображение'; + + @override + String get imageUnavailable => 'Изображение сейчас недоступно'; + + @override + String get imageFetchTimedOut => 'Время ожидания изображения истекло'; + + @override + String get imageReceiveCanceled => 'Приём изображения отменён'; + + @override + String get closeImagePreview => 'Закрыть просмотр изображения'; + + @override + String get recordingReleaseToSend => 'Запись... отпустите, чтобы отправить'; + + @override + String get sendingVoice => 'Отправка голоса...'; + + @override + String get sendLongPressToRecordVoice => + 'Отправить (удерживайте для записи голоса)'; + + @override + String get discardRecording => 'Удалить запись'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count хопа', + many: '$count хопов', + few: '$count хопа', + one: '$count хоп', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Широковещательные каналы для mesh-трафика поблизости'; + + @override + String get roomsSectionDescription => + 'Общие пространства для постоянной координации команды'; + + @override + String get contactsSectionDescription => + 'Люди и устройства в прямой досягаемости'; + + @override + String get noRecentChatters => 'Нет недавних собеседников'; + + @override + String get public => 'Публичный'; + + @override + String get failedToSendTicTacToeMove => + 'Не удалось отправить ход в крестики-нолики'; + + @override + String get youWon => 'Вы победили'; + + @override + String get opponentWon => 'Победил соперник'; + + @override + String get gameDraw => 'Ничья'; + + @override + String get yourTurn => 'Ваш ход'; + + @override + String get opponentTurn => 'Ход соперника'; + + @override + String get pleaseEnterValidCoordinates => 'Введите корректные координаты'; + + @override + String get locationNotAvailable => 'Местоположение недоступно'; + + @override + String get customCaveMapPoint => 'Пользовательская точка карты пещеры'; + + @override + String get mapPoint => 'Точка карты'; + + @override + String get fetchingMissingImageFragments => + 'Получение недостающих фрагментов…'; + + @override + String get loadingImage => 'Загрузка…'; + + @override + String get receivingImage => 'Приём…'; + + @override + String get tapToLoad => 'Нажмите, чтобы загрузить'; + + @override + String get connectDevice => 'Подключить устройство'; + + @override + String get chooseTransportSubtitle => + 'Выберите транспорт: Bluetooth, WiFi или последовательный порт'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Сеть'; + + @override + String get serial => 'Последовательный порт'; + + @override + String get scan => 'Сканировать'; + + @override + String get pressScanToSearchForDevices => + 'Нажмите «Сканировать», чтобы найти устройства поблизости'; + + @override + String failedToConnectToDevice(String name) { + return 'Не удалось подключиться к $name'; + } + + @override + String get unknownDevice => 'Неизвестное устройство'; + + @override + String get showingCachedResultsTapRefresh => + 'Показаны кэшированные результаты. Нажмите «Обновить», чтобы пересканировать.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Сканирование локальной сети на наличие WiFi-устройств MeshCore на порту $port'; + } + + @override + String get cancelScanAndAddServer => + 'Отменить сканирование и добавить сервер'; + + @override + String get addServer => 'Добавить сервер'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Сканирование... $scanned/$total IP-адресов'; + } + + @override + String get cancelAndEnterManually => 'Отменить и ввести вручную'; + + @override + String get noRecentOrDiscoveredServers => + 'Пока нет недавних или обнаруженных серверов'; + + @override + String get recentlyUsed => 'Недавно использованные'; + + @override + String get connecting => 'Подключение...'; + + @override + String get discoveredOnThisNetwork => 'Обнаружены в этой сети'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'Сервер $host:$port больше недоступен. Выполните сканирование снова, чтобы найти активные серверы.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Не удалось подключиться к $host:$port'; + } + + @override + String get enterValidIpAddress => 'Введите корректный IP-адрес'; + + @override + String get enterValidTcpPort => 'Введите корректный TCP-порт'; + + @override + String get ipAddress => 'IP-адрес'; + + @override + String get tcpPort => 'TCP-порт'; + + @override + String get customServerPort => 'Пользовательский порт сервера'; + + @override + String get chat => 'Чат'; + + @override + String get restoringPreviousLink => 'Восстановление предыдущего подключения'; + + @override + String get noDeviceConnected => 'Нет подключённого устройства'; + + @override + String get sendAdvert => 'Отправить мой контакт'; + + @override + String get advertMode => 'Отправить мой контакт'; + + @override + String get advertModeSubtitle => + 'Выберите, как далеко должны распространиться ваши контактные данные.'; + + @override + String get floodAdvertSent => 'Контакт отправлен по всей mesh-сети'; + + @override + String get directAdvertSent => 'Контакт отправлен ближайшим устройствам'; +} diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart new file mode 100644 index 0000000..416b7e2 --- /dev/null +++ b/lib/l10n/app_localizations_sl.dart @@ -0,0 +1,3437 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Slovenian (`sl`). +class AppLocalizationsSl extends AppLocalizations { + AppLocalizationsSl([String locale = 'sl']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Sporočila'; + + @override + String get contacts => 'Stiki'; + + @override + String get map => 'Zemljevid'; + + @override + String get settings => 'Nastavitve'; + + @override + String get connect => 'Poveži'; + + @override + String get disconnect => 'Prekini'; + + @override + String get noDevicesFound => 'Ni najdenih naprav'; + + @override + String get scanAgain => 'Ponovi iskanje'; + + @override + String get deviceNotConnected => 'Naprava ni povezana'; + + @override + String get locationPermissionDenied => 'Dovoljenje za lokacijo zavrnjeno'; + + @override + String get locationPermissionPermanentlyDenied => + 'Dovoljenje za lokacijo trajno zavrnjeno. Prosimo, omogočite v Nastavitvah.'; + + @override + String get locationPermissionRequired => + 'Dovoljenje za lokacijo je potrebno za GPS sledenje in usklajevanje ekipe. Lahko ga omogočite kasneje v Nastavitvah.'; + + @override + String get locationServicesDisabled => + 'Lokacijske storitve so onemogočene. Prosimo, omogočite jih v Nastavitvah.'; + + @override + String get failedToGetGpsLocation => 'Pridobitev GPS lokacije ni uspela'; + + @override + String failedToAdvertise(String error) { + return 'Objava ni uspela: $error'; + } + + @override + String get cancelReconnection => 'Prekliči ponovno povezovanje'; + + @override + String get general => 'Splošno'; + + @override + String get theme => 'Tema'; + + @override + String get chooseTheme => 'Izberite temo'; + + @override + String get light => 'Svetla'; + + @override + String get dark => 'Temna'; + + @override + String get blueLightTheme => 'Modra svetla tema'; + + @override + String get blueDarkTheme => 'Modra temna tema'; + + @override + String get sarRed => 'SAR rdeča'; + + @override + String get alertEmergencyMode => 'Način opozorila/nujni primer'; + + @override + String get sarGreen => 'SAR zelena'; + + @override + String get safeAllClearMode => 'Način varno/vse jasno'; + + @override + String get autoSystem => 'Samodejno (Sistem)'; + + @override + String get followSystemTheme => 'Sledi sistemski temi'; + + @override + String get showRxTxIndicators => 'Prikaži RX/TX kazalnike'; + + @override + String get disableMap => 'Onemogoči zemljevid'; + + @override + String get language => 'Jezik'; + + @override + String get chooseLanguage => 'Izberite jezik'; + + @override + String get save => 'Shrani'; + + @override + String get cancel => 'Prekliči'; + + @override + String get close => 'Zapri'; + + @override + String get about => 'O aplikaciji'; + + @override + String get appVersion => 'Različica aplikacije'; + + @override + String get aboutMeshCoreSar => 'O MeshCore SAR'; + + @override + String get 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'; + + @override + String get technologiesUsed => 'Uporabljene tehnologije:'; + + @override + String get technologiesList => + '• Flutter za večplatformni razvoj\n• BLE (Bluetooth Low Energy) za mesh omrežje\n• OpenStreetMap za kartografijo\n• Provider za upravljanje stanja\n• SharedPreferences za lokalno shranjevanje'; + + @override + String get moreInfo => 'Več informacij'; + + @override + String get packageName => 'Ime paketa'; + + @override + String get sampleData => 'Vzorčni podatki'; + + @override + String get sampleDataDescription => + 'Naložite ali počistite vzorčne stike, sporočila kanalov in SAR označevalce za testiranje'; + + @override + String get loadSampleData => 'Naloži vzorec'; + + @override + String get clearAllData => 'Počisti vse podatke'; + + @override + String get clearAllDataConfirmTitle => 'Počisti vse podatke'; + + @override + String get clearAllDataConfirmMessage => + 'To bo počistilo vse stike in SAR označevalce. Ste prepričani?'; + + @override + String get clear => 'Počisti'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Naloženih $teamCount članov ekipe, $channelCount kanalov, $sarCount SAR označevalcev, $messageCount sporočil'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Nalaganje vzorčnih podatkov ni uspelo: $error'; + } + + @override + String get allDataCleared => 'Vsi podatki počiščeni'; + + @override + String get failedToStartBackgroundTracking => + 'Zagon sledenja v ozadju ni uspel. Preverite dovoljenja in BLE povezavo.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Oddajanje lokacije: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'Privzeta PIN koda za naprave brez zaslona je 123456. Težave s seznanitvijo? Pozabite napravo Bluetooth v sistemskih nastavitvah.'; + + @override + String get noMessagesYet => 'Še ni sporočil'; + + @override + String get pullDownToSync => 'Potegnite navzdol za sinhronizacijo'; + + @override + String get deleteContact => 'Izbriši stik'; + + @override + String get delete => 'Izbriši'; + + @override + String get viewOnMap => 'Poglej na zemljevidu'; + + @override + String get refresh => 'Osveži'; + + @override + String get resetPath => 'Ponastavi pot (preusmeri)'; + + @override + String copiedToClipboard(String label) { + return '$label kopirano v odložišče'; + } + + @override + String get pleaseEnterPassword => 'Prosimo, vnesite geslo'; + + @override + String failedToSyncContacts(String error) { + return 'Sinhronizacija stikov ni uspela: $error'; + } + + @override + String get loggedInSuccessfully => + 'Uspešno prijavljen! Čakanje na sporočila sobe...'; + + @override + String get loginFailed => 'Prijava ni uspela - nepravilno geslo'; + + @override + String loggingIn(String roomName) { + return 'Prijavljanje v $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Pošiljanje prijave ni uspelo: $error'; + } + + @override + String get lowLocationAccuracy => 'Nizka natančnost lokacije'; + + @override + String get continue_ => 'Nadaljuj'; + + @override + String get sendSarMarker => 'Pošlji SAR označevalec'; + + @override + String get deleteDrawing => 'Izbriši risbo'; + + @override + String get drawingTools => 'Orodja za risanje'; + + @override + String get drawLine => 'Nariši črto'; + + @override + String get drawLineDesc => 'Nariši prosto črto na zemljevidu'; + + @override + String get drawRectangle => 'Nariši pravokotnik'; + + @override + String get drawRectangleDesc => 'Nariši pravokotno področje na zemljevidu'; + + @override + String get measureDistance => 'Meri razdaljo'; + + @override + String get measureDistanceDesc => 'Dolg pritisk na dve točki za merjenje'; + + @override + String get clearMeasurement => 'Počisti meritev'; + + @override + String distanceLabel(String distance) { + return 'Razdalja: $distance'; + } + + @override + String get longPressForSecondPoint => 'Dolg pritisk za drugo točko'; + + @override + String get longPressToStartMeasurement => 'Dolg pritisk za prvo točko'; + + @override + String get longPressToStartNewMeasurement => 'Dolg pritisk za novo meritev'; + + @override + String get shareDrawings => 'Deli risbe'; + + @override + String get clearAllDrawings => 'Počisti vse risbe'; + + @override + String get completeLine => 'Dokonč črto'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Oddaj $count risb$plural ekipi'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Odstrani vseh $count risb$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Izbriši vseh $count risb$plural z zemljevida?'; + } + + @override + String get drawing => 'Risanje'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Deli $count risb$plural'; + } + + @override + String get showReceivedDrawings => 'Prikaži prejete risbe'; + + @override + String get showingAllDrawings => 'Prikazujem vse risbe'; + + @override + String get showingOnlyYourDrawings => 'Prikazujem samo vaše risbe'; + + @override + String get showSarMarkers => 'Prikaži SAR označevalce'; + + @override + String get showingSarMarkers => 'Prikazujem SAR označevalce'; + + @override + String get hidingSarMarkers => 'Skrivam SAR označevalce'; + + @override + String get clearAll => 'Počisti vse'; + + @override + String get publicChannel => 'Javni kanal'; + + @override + String get broadcastToAll => 'Oddajaj vsem bližnjim vozliščem (začasno)'; + + @override + String get storedPermanently => 'Trajno shranjeno v sobi'; + + @override + String get notConnectedToDevice => 'Ni povezano z napravo'; + + @override + String get typeYourMessage => 'Vnesite svoje sporočilo...'; + + @override + String get quickLocationMarker => 'Hitri označevalec lokacije'; + + @override + String get markerType => 'Vrsta označevalca'; + + @override + String get sendTo => 'Pošlji na'; + + @override + String get noDestinationsAvailable => 'Ni dostopnih ciljev.'; + + @override + String get selectDestination => 'Izberite cilj...'; + + @override + String get ephemeralBroadcastInfo => + 'Začasno: Samo oddajanje. Ni shranjeno - vozlišča morajo biti povezana.'; + + @override + String get persistentRoomInfo => + 'Trajno: Nespremenljivo shranjeno v sobi. Samodejno sinhronizirano in ohranjeno brez povezave.'; + + @override + String get location => 'Lokacija'; + + @override + String get fromMap => 'Z zemljevida'; + + @override + String get gettingLocation => 'Pridobivanje lokacije...'; + + @override + String get locationError => 'Napaka lokacije'; + + @override + String get retry => 'Poskusi znova'; + + @override + String get refreshLocation => 'Osveži lokacijo'; + + @override + String accuracyMeters(int accuracy) { + return 'Natančnost: ±${accuracy}m'; + } + + @override + String get notesOptional => 'Opombe (neobvezno)'; + + @override + String get addAdditionalInformation => 'Dodajte dodatne informacije...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'Natančnost lokacije je ±${accuracy}m. To morda ni dovolj natančno za SAR operacije.\n\nVseeno nadaljuj?'; + } + + @override + String get loginToRoom => 'Prijava v sobo'; + + @override + String get enterPasswordInfo => + 'Vnesite geslo za dostop do te sobe. Geslo bo shranjeno za prihodnjo uporabo.'; + + @override + String get password => 'Geslo'; + + @override + String get enterRoomPassword => 'Vnesite geslo sobe'; + + @override + String get loggingInDots => 'Prijavljanje...'; + + @override + String get login => 'Prijava'; + + @override + String failedToAddRoom(String error) { + return 'Dodajanje sobe v napravo ni uspelo: $error\n\nSoba morda še ni oglašena.\nPoskusite počakati, da soba odda.'; + } + + @override + String get direct => 'Neposredno'; + + @override + String get flood => 'Razpršitev'; + + @override + String get autoSend => 'Samodejno pošiljanje'; + + @override + String get autoSendDescription => 'Pošlji po trenutni poti.'; + + @override + String get sendDirect => 'Neposredno pošiljanje'; + + @override + String get sendDirectDescription => 'Pošlji neposredno temu kontaktu.'; + + @override + String get sendFlood => 'Pošiljanje s poplavo'; + + @override + String get sendFloodDescription => 'Pošlji prek vseh repetitorjev.'; + + @override + String get loggedIn => 'Prijavljen'; + + @override + String get noGpsData => 'Ni GPS podatkov'; + + @override + String get distance => 'Razdalja'; + + @override + String directPingTimeout(String name) { + return 'Časovna omejitev neposrednega pinga - ponovni poskus $name z razprševanjem...'; + } + + @override + String pingFailed(String name) { + return 'Ping neuspešen do $name - odgovor ni prejet'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Ste prepričani, da želite izbrisati \"$name\"?\n\nTo bo odstranilo stik iz aplikacije in spremljevalne radijske naprave.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Odstranjevanje stika ni uspelo: $error'; + } + + @override + String get type => 'Vrsta'; + + @override + String get publicKey => 'Javni ključ'; + + @override + String get lastSeen => 'Nazadnje viden'; + + @override + String get yes => 'Da'; + + @override + String get no => 'Ne'; + + @override + String get permissions => 'Dovoljenja'; + + @override + String get telemetry => 'Telemetrija'; + + @override + String get voltage => 'Napetost'; + + @override + String get battery => 'Baterija'; + + @override + String get temperature => 'Temperatura'; + + @override + String get humidity => 'Vlažnost'; + + @override + String get pressure => 'Tlak'; + + @override + String get gpsTelemetry => 'GPS (Telemetrija)'; + + @override + String get reLoginToRoom => 'Ponovna prijava v sobo'; + + @override + String get heading => 'Smer'; + + @override + String get elevation => 'Nadmorska višina'; + + @override + String get accuracy => 'Natančnost'; + + @override + String get bearing => 'Azimut'; + + @override + String get direction => 'Smer'; + + @override + String get filterMarkers => 'Filtriraj označevalce'; + + @override + String get filterMarkersTooltip => 'Filtriraj označevalce'; + + @override + String get contactsFilter => 'Stiki'; + + @override + String get repeatersFilter => 'Ponavljalniki'; + + @override + String get sarMarkers => 'SAR označevalci'; + + @override + String get foundPerson => 'Najdena oseba'; + + @override + String get fire => 'Ogenj'; + + @override + String get stagingArea => 'Zbirališče'; + + @override + String get showAll => 'Prikaži vse'; + + @override + String get locationUnavailable => 'Lokacija ni na voljo'; + + @override + String get ahead => 'naravnost'; + + @override + String degreesRight(int degrees) { + return '$degrees° desno'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° levo'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Šir: $latitude Dolž: $longitude'; + } + + @override + String get noContactsYet => 'Še ni stikov'; + + @override + String get connectToDeviceToLoadContacts => + 'Povežite se z napravo za nalaganje stikov'; + + @override + String get teamMembers => 'Člani ekipe'; + + @override + String get repeaters => 'Ponavljalniki'; + + @override + String get rooms => 'Sobe'; + + @override + String get channels => 'Kanali'; + + @override + String get selectMapLayer => 'Izberite sloj zemljevida'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'ESRI satelit'; + + @override + String get googleHybrid => 'Google hibridni zemljevid'; + + @override + String get googleRoadmap => 'Google cestni zemljevid'; + + @override + String get googleTerrain => 'Google teren'; + + @override + String get dragToPosition => 'Povleci na položaj'; + + @override + String get createSarMarker => 'Ustvari SAR označevalec'; + + @override + String get compass => 'Kompas'; + + @override + String get navigationAndContacts => 'Navigacija in stiki'; + + @override + String get sarAlert => 'SAR ALARM'; + + @override + String get textCopiedToClipboard => 'Besedilo kopirano v odložišče'; + + @override + String get cannotReplySenderMissing => + 'Ni mogoče odgovoriti: informacije o pošiljatelju manjkajo'; + + @override + String get cannotReplyContactNotFound => + 'Ni mogoče odgovoriti: stik ni najden'; + + @override + String get copyText => 'Kopiraj besedilo'; + + @override + String get saveAsTemplate => 'Shrani kot predlogo'; + + @override + String get templateSaved => 'Predloga uspešno shranjena'; + + @override + String get templateAlreadyExists => 'Predloga s tem emojijem že obstaja'; + + @override + String get deleteMessage => 'Izbriši sporočilo'; + + @override + String get deleteMessageConfirmation => + 'Ali ste prepričani, da želite izbrisati to sporočilo?'; + + @override + String get shareLocation => 'Deli lokacijo'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nKoordinate: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'SAR Lokacija'; + + @override + String get justNow => 'Pravkar'; + + @override + String minutesAgo(int minutes) { + return 'pred ${minutes}m'; + } + + @override + String hoursAgo(int hours) { + return 'pred ${hours}h'; + } + + @override + String daysAgo(int days) { + return 'pred ${days}d'; + } + + @override + String secondsAgo(int seconds) { + return 'pred ${seconds}s'; + } + + @override + String get sending => 'Pošiljanje...'; + + @override + String get sent => 'Poslano'; + + @override + String get delivered => 'Dostavljeno'; + + @override + String deliveredWithTime(int time) { + return 'Dostavljeno (${time}ms)'; + } + + @override + String get failed => 'Neuspešno'; + + @override + String get broadcast => 'Oddajano'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Dostavljeno $delivered/$total stikom'; + } + + @override + String get allDelivered => 'Vse dostavljeno'; + + @override + String get recipientDetails => 'Podrobnosti prejemnikov'; + + @override + String get pending => 'V čakanju'; + + @override + String get sarMarkerFoundPerson => 'Najdena oseba'; + + @override + String get sarMarkerFire => 'Lokacija ognja'; + + @override + String get sarMarkerStagingArea => 'Zbirališče'; + + @override + String get sarMarkerObject => 'Najden predmet'; + + @override + String get from => 'Od'; + + @override + String get coordinates => 'Koordinate'; + + @override + String get tapToViewOnMap => 'Tapnite za prikaz na zemljevidu'; + + @override + String get radioSettings => 'Nastavitve radia'; + + @override + String get frequencyMHz => 'Frekvenca (MHz)'; + + @override + String get bandwidth => 'Pasovna širina'; + + @override + String get spreadingFactor => 'Faktor razširitve'; + + @override + String get codingRate => 'Razmerje kodiranja'; + + @override + String get txPowerDbm => 'Izhodna moč (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Največ: $power dBm'; + } + + @override + String get you => 'Ti'; + + @override + String exportFailed(String error) { + return 'Izvoz ni uspel: $error'; + } + + @override + String importFailed(String error) { + return 'Uvoz ni uspel: $error'; + } + + @override + String get unknown => 'Neznano'; + + @override + String get onlineLayers => 'Spletne plasti'; + + @override + String get locationTrail => 'Sledilna pot'; + + @override + String get showTrailOnMap => 'Prikaži pot na zemljevidu'; + + @override + String get trailVisible => 'Pot je vidna na zemljevidu'; + + @override + String get trailHiddenRecording => 'Pot je skrita (še se snema)'; + + @override + String get duration => 'Trajanje'; + + @override + String get points => 'Točke'; + + @override + String get clearTrail => 'Počisti pot'; + + @override + String get clearTrailQuestion => 'Počisti pot?'; + + @override + String get clearTrailConfirmation => + 'Ste prepričani, da želite počistiti trenutno sledilno pot? Tega dejanja ni mogoče razveljaviti.'; + + @override + String get noTrailRecorded => 'Še ni posnete poti'; + + @override + String get startTrackingToRecord => + 'Začnite sledenje lokacije za snemanje poti'; + + @override + String get trailControls => 'Nadzor poti'; + + @override + String get model => 'Model'; + + @override + String get version => 'Različica'; + + @override + String get maxContacts => 'Maks. stikov'; + + @override + String get maxChannels => 'Maks. kanalov'; + + @override + String get publicInfo => 'Javne informacije'; + + @override + String get useCurrentLocation => 'Uporabi trenutno lokacijo'; + + @override + String get noneUnknown => 'Brez/Neznano'; + + @override + String get chatNode => 'Vozlišče za klepet'; + + @override + String get repeater => 'Ponavljalnik'; + + @override + String get roomChannel => 'Soba/Kanal'; + + @override + String typeNumber(int number) { + return 'Tip $number'; + } + + @override + String failedToSave(String error) { + return 'Shranjevanje ni uspelo: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Pridobivanje lokacije ni uspelo: $error'; + } + + @override + String get sarTemplates => 'SAR predloge'; + + @override + String get addTemplate => 'Dodaj predlogo'; + + @override + String get editTemplate => 'Uredi predlogo'; + + @override + String get deleteTemplate => 'Izbriši predlogo'; + + @override + String get templateName => 'Ime predloge'; + + @override + String get templateNameHint => 'npr. Najdena oseba'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji je obvezen'; + + @override + String get nameRequired => 'Ime je obvezno'; + + @override + String get templateDescription => 'Opis (neobvezno)'; + + @override + String get templateDescriptionHint => 'Dodajte dodatni kontekst...'; + + @override + String get templateColor => 'Barva'; + + @override + String get previewFormat => 'Predogled (oblika SAR sporočila)'; + + @override + String get importFromClipboard => 'Uvozi'; + + @override + String get exportToClipboard => 'Izvozi'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Izbrišem predlogo \'$name\'?'; + } + + @override + String get templateAdded => 'Predloga dodana'; + + @override + String get templateUpdated => 'Predloga posodobljena'; + + @override + String get templateDeleted => 'Predloga izbrisana'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Uvoženih $count predlog', + one: 'Uvožena 1 predloga', + zero: 'Ni uvoženih predlog', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Izvoženih $count predlog v odložišče', + one: 'Izvožena 1 predloga v odložišče', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Ponastavi na privzeto'; + + @override + String get resetToDefaultsConfirmation => + 'To bo izbrisalo vse prilagojene predloge in obnovilo 4 privzete predloge. Nadaljevati?'; + + @override + String get reset => 'Ponastavi'; + + @override + String get resetComplete => 'Predloge ponastavljene na privzeto'; + + @override + String get noTemplates => 'Ni razpoložljivih predlog'; + + @override + String get tapAddToCreate => 'Tapnite + za ustvarjanje prve predloge'; + + @override + String get ok => 'V redu'; + + @override + String get locationPermission => 'Dovoljenje za lokacijo'; + + @override + String get checking => 'Preverjanje...'; + + @override + String get locationPermissionGrantedAlways => 'Odobreno (Vedno)'; + + @override + String get locationPermissionGrantedWhileInUse => 'Odobreno (Med uporabo)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Zavrnjeno - Tapnite za zahtevo'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Trajno zavrnjeno - Odpri nastavitve'; + + @override + String get locationPermissionDialogContent => + 'Dovoljenje za lokacijo je trajno zavrnjeno. Omogočite ga v nastavitvah naprave za uporabo sledenja GPS in deljenja lokacije.'; + + @override + String get openSettings => 'Odpri nastavitve'; + + @override + String get locationPermissionGranted => 'Dovoljenje za lokacijo odobreno!'; + + @override + String get locationPermissionRequiredForGps => + 'Dovoljenje za lokacijo je potrebno za sledenje GPS in deljenje lokacije.'; + + @override + String get locationPermissionAlreadyGranted => + 'Dovoljenje za lokacijo je že odobreno.'; + + @override + String get sarNavyBlue => 'SAR Mornarska Modra'; + + @override + String get sarNavyBlueDescription => 'Profesionalni/Operativni Način'; + + @override + String get selectRecipient => 'Izberi prejemnika'; + + @override + String get broadcastToAllNearby => 'Oddajaj vsem v bližini'; + + @override + String get searchRecipients => 'Išči prejemnike...'; + + @override + String get noContactsFound => 'Ni kontaktov'; + + @override + String get noRoomsFound => 'Ni sob'; + + @override + String get noRecipientsAvailable => 'Ni na voljo prejemnikov'; + + @override + String get noChannelsFound => 'Ni najdenih kanalov'; + + @override + String get newMessage => 'Novo sporočilo'; + + @override + String get channel => 'Kanal'; + + @override + String get samplePoliceLead => 'Vodja Policije'; + + @override + String get sampleDroneOperator => 'Operater Drona'; + + @override + String get sampleFirefighterAlpha => 'Gasilec'; + + @override + String get sampleMedicCharlie => 'Zdravnik'; + + @override + String get sampleCommandDelta => 'Poveljstvo'; + + @override + String get sampleFireEngine => 'Gasilsko Vozilo'; + + @override + String get sampleAirSupport => 'Zračna Podpora'; + + @override + String get sampleBaseCoordinator => 'Koordinator Baze'; + + @override + String get channelEmergency => 'Nujno'; + + @override + String get channelCoordination => 'Koordinacija'; + + @override + String get channelUpdates => 'Posodobitve'; + + @override + String get sampleTeamMember => 'Vzorčni Član Ekipe'; + + @override + String get sampleScout => 'Vzorčni Izvidnik'; + + @override + String get sampleBase => 'Vzorčna Baza'; + + @override + String get sampleSearcher => 'Vzorčni Iskalec'; + + @override + String get sampleObjectBackpack => ' Najden nahrbtnik - modra barva'; + + @override + String get sampleObjectVehicle => ' Zapuščeno vozilo - preveriti lastnika'; + + @override + String get sampleObjectCamping => ' Odkrita oprema za kampiranje'; + + @override + String get sampleObjectTrailMarker => ' Oznaka poti najdena izven poti'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Vse ekipe se javite'; + + @override + String get sampleMsgWeatherUpdate => + 'Posodobitev vremena: Jasno nebo, temp 18°C'; + + @override + String get sampleMsgBaseCamp => 'Bazni tabor vzpostavljen na zbirališču'; + + @override + String get sampleMsgTeamAlpha => 'Ekipa se premika v sektor 2'; + + @override + String get sampleMsgRadioCheck => + 'Preverjanje radia - vse postaje odgovorite'; + + @override + String get sampleMsgWaterSupply => + 'Oskrba z vodo na voljo na kontrolni točki 3'; + + @override + String get sampleMsgTeamBravo => 'Ekipa poroča: sektor 1 čist'; + + @override + String get sampleMsgEtaRallyPoint => 'Prihod na zbirališče: 15 minut'; + + @override + String get sampleMsgSupplyDrop => 'Dostava zalog potrjena za 14:00'; + + @override + String get sampleMsgDroneSurvey => 'Nadzor z dronom zaključen - brez najdb'; + + @override + String get sampleMsgTeamCharlie => 'Ekipa prosi za okrepitev'; + + @override + String get sampleMsgRadioDiscipline => + 'Vse enote: vzdrževati radijsko disciplino'; + + @override + String get sampleMsgUrgentMedical => + 'NUJNO: Potrebna medicinska pomoč v sektorju 4'; + + @override + String get sampleMsgAdultMale => ' Odrasel moški, pri zavesti'; + + @override + String get sampleMsgFireSpotted => 'Opažen požar - koordinate sledijo'; + + @override + String get sampleMsgSpreadingRapidly => ' Hitro se širi!'; + + @override + String get sampleMsgPriorityHelicopter => + 'PRIORITETA: Potrebna podpora helikopterja'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Medicinska ekipa na poti do vaše lokacije'; + + @override + String get sampleMsgEvacHelicopter => + 'Helikopter za evakuacijo prihod 10 minut'; + + @override + String get sampleMsgEmergencyResolved => 'Nujna situacija rešena – vse čisto'; + + @override + String get sampleMsgEmergencyStagingArea => ' Nujno zbirališče'; + + @override + String get sampleMsgEmergencyServices => + 'Nujne službe obveščene in se odzivajo'; + + @override + String get sampleAlphaTeamLead => 'Vodja Ekipe'; + + @override + String get sampleBravoScout => 'Izvidnik'; + + @override + String get sampleCharlieMedic => 'Zdravnik'; + + @override + String get sampleDeltaNavigator => 'Navigator'; + + @override + String get sampleEchoSupport => 'Podpora'; + + @override + String get sampleBaseCommand => 'Poveljstvo Baze'; + + @override + String get sampleFieldCoordinator => 'Terenski Koordinator'; + + @override + String get sampleMedicalTeam => 'Medicinska Ekipa'; + + @override + String get mapDrawing => 'Risba zemljevida'; + + @override + String get navigateToDrawing => 'Navigiraj do risbe'; + + @override + String get copyCoordinates => 'Kopiraj koordinate'; + + @override + String get hideFromMap => 'Skrij z zemljevida'; + + @override + String get lineDrawing => 'Linijska risba'; + + @override + String get rectangleDrawing => 'Pravokotna risba'; + + @override + String get manualCoordinates => 'Ročne koordinate'; + + @override + String get enterCoordinatesManually => 'Ročno vnesite koordinate'; + + @override + String get latitudeLabel => 'Geografska širina'; + + @override + String get longitudeLabel => 'Geografska dolžina'; + + @override + String get exampleCoordinates => 'Primer: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Deli risbo'; + + @override + String get shareWithAllNearbyDevices => 'Deli z vsemi bližnjimi napravami'; + + @override + String get shareToRoom => 'Deli v Sobo'; + + @override + String get sendToPersistentStorage => 'Pošlji v trajno shrambo sobe'; + + @override + String get deleteDrawingConfirm => + 'Ali ste prepričani, da želite izbrisati to risbo?'; + + @override + String get drawingDeleted => 'Risba izbrisana'; + + @override + String yourDrawingsCount(int count) { + return 'Vaše risbe ($count)'; + } + + @override + String get shared => 'Deljeno'; + + @override + String get line => 'Črta'; + + @override + String get rectangle => 'Pravokotnik'; + + @override + String get updateAvailable => 'Na voljo je posodobitev'; + + @override + String get currentVersion => 'Trenutna različica'; + + @override + String get latestVersion => 'Najnovejša različica'; + + @override + String get downloadUpdate => 'Prenesi posodobitev'; + + @override + String get updateLater => 'Kasneje'; + + @override + String get cadastralParcels => 'Katastrske parcele'; + + @override + String get forestRoads => 'Gozdne ceste'; + + @override + String get wmsOverlays => 'WMS prekrivanja'; + + @override + String get hikingTrails => 'Planinske poti'; + + @override + String get mainRoads => 'Glavne ceste'; + + @override + String get houseNumbers => 'Hišne številke'; + + @override + String get fireHazardZones => 'Požarna ogroženost'; + + @override + String get historicalFires => 'Zgodovinski požari'; + + @override + String get firebreaks => 'Protipožarne preseke'; + + @override + String get krasFireZones => 'Kraška požarišča'; + + @override + String get placeNames => 'Zemljepisna imena'; + + @override + String get municipalityBorders => 'Občinske meje'; + + @override + String get topographicMap => 'Topografska karta 1:25000'; + + @override + String get recentMessages => 'Nedavna sporočila'; + + @override + String get addChannel => 'Dodaj kanal'; + + @override + String get channelName => 'Ime kanala'; + + @override + String get channelNameHint => 'npr. Reševalna ekipa Alfa'; + + @override + String get channelSecret => 'Geslo kanala'; + + @override + String get channelSecretHint => 'Skupno geslo za ta kanal'; + + @override + String get channelSecretHelp => + 'To geslo mora biti deljeno z vsemi člani ekipe, ki potrebujejo dostop do tega kanala'; + + @override + String get channelTypesInfo => + 'Hash kanali (#ekipa): Geslo samodejno generirano iz imena. Enako ime = isti kanal na vseh napravah.\n\nZasebni kanali: Uporabite eksplicitno geslo. Samo tisti z geslom se lahko pridružijo.'; + + @override + String get hashChannelInfo => + 'Hash kanal: Geslo bo samodejno generirano iz imena kanala. Kdorkoli uporabi isto ime, se bo pridružil istemu kanalu.'; + + @override + String get channelNameRequired => 'Ime kanala je obvezno'; + + @override + String get channelNameTooLong => 'Ime kanala mora imeti največ 31 znakov'; + + @override + String get channelSecretRequired => 'Geslo kanala je obvezno'; + + @override + String get channelSecretTooLong => 'Geslo kanala mora imeti največ 32 znakov'; + + @override + String get invalidAsciiCharacters => 'Dovoljeni so samo ASCII znaki'; + + @override + String get channelCreatedSuccessfully => 'Kanal uspešno ustvarjen'; + + @override + String channelCreationFailed(String error) { + return 'Neuspešno ustvarjanje kanala: $error'; + } + + @override + String get deleteChannel => 'Izbriši kanal'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Ali ste prepričani, da želite izbrisati kanal \"$channelName\"? Tega dejanja ni mogoče razveljaviti.'; + } + + @override + String get channelDeletedSuccessfully => 'Kanal uspešno izbrisan'; + + @override + String channelDeletionFailed(String error) { + return 'Neuspešno brisanje kanala: $error'; + } + + @override + String get createChannel => 'Ustvari kanal'; + + @override + String get wizardBack => 'Nazaj'; + + @override + String get wizardSkip => 'Preskoči'; + + @override + String get wizardNext => 'Naprej'; + + @override + String get wizardGetStarted => 'Začni'; + + @override + String get wizardWelcomeTitle => 'Dobrodošli v MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Ogled vadnice dobrodošlice'; + + @override + String get allTeamContacts => 'Vsi stiki ekipe'; + + @override + String directMessagesInfo(int count) { + return 'Neposredna sporočila s potrditvami. Poslano $count članom ekipe.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'SAR označevalec poslan $count stikom'; + } + + @override + String get noContactsAvailable => 'Ni razpoložljivih stikov ekipe'; + + @override + String get reply => 'Odgovori'; + + @override + String get technicalDetails => 'Tehnični podrobnosti'; + + @override + String get messageTechnicalDetails => 'Tehnični podrobnosti sporočila'; + + @override + String get linkQuality => 'Kakovost povezave'; + + @override + String get delivery => 'Dostava'; + + @override + String get status => 'Stanje'; + + @override + String get expectedAckTag => 'Pričakovana oznaka ACK'; + + @override + String get roundTrip => 'Povratna pot'; + + @override + String get retryAttempt => 'Poskus ponovnega pošiljanja'; + + @override + String get floodFallback => 'Flood rezerva'; + + @override + String get identity => 'Identiteta'; + + @override + String get messageId => 'ID sporočila'; + + @override + String get sender => 'Pošiljatelj'; + + @override + String get senderKey => 'Ključ pošiljatelja'; + + @override + String get recipient => 'Prejemnik'; + + @override + String get recipientKey => 'Ključ prejemnika'; + + @override + String get voice => 'Glas'; + + @override + String get voiceId => 'ID glasu'; + + @override + String get envelope => 'Ovojnica'; + + @override + String get sessionProgress => 'Napredek seje'; + + @override + String get complete => 'Dokončano'; + + @override + String get rawDump => 'Surovi izpis'; + + @override + String get cannotRetryMissingRecipient => + 'Ponovnega pošiljanja ni mogoče: informacije o prejemniku manjkajo'; + + @override + String get voiceUnavailable => 'Glas trenutno ni na voljo'; + + @override + String get requestingVoice => 'Zahteva za glasom'; + + @override + String get device => 'naprava'; + + @override + String get change => 'Spremeni'; + + @override + String get wizardOverviewDescription => + 'Ta aplikacija združuje sporočanje MeshCore, terenske SAR posodobitve, zemljevide in orodja za napravo na enem mestu.'; + + @override + String get wizardOverviewFeature1 => + 'Pošiljajte neposredna sporočila, objave v sobah in sporočila na kanalih iz glavnega zavihka Sporočila.'; + + @override + String get wizardOverviewFeature2 => + 'Delite SAR označevalce, risbe zemljevida, glasovne posnetke in slike prek mesh omrežja.'; + + @override + String get wizardOverviewFeature3 => + 'Povežite se prek BLE ali TCP in nato upravljajte spremljevalni radio kar iz aplikacije.'; + + @override + String get wizardMessagingTitle => 'Sporočanje in terenska poročila'; + + @override + String get wizardMessagingDescription => + 'Sporočila tukaj niso le navadno besedilo. Aplikacija že podpira več operativnih vrst vsebin in prenosnih tokov.'; + + @override + String get wizardMessagingFeature1 => + 'Pošiljajte neposredna sporočila, objave v sobah in promet na kanalih iz enega urejevalnika.'; + + @override + String get wizardMessagingFeature2 => + 'Ustvarjajte SAR posodobitve in večkrat uporabne SAR predloge za pogosta terenska poročila.'; + + @override + String get wizardMessagingFeature3 => + 'Prenašajte glasovne seje in slike, vmesnik pa prikazuje napredek in oceno časa prenosa.'; + + @override + String get wizardConnectDeviceTitle => 'Poveži napravo'; + + @override + String get wizardConnectDeviceDescription => + 'Povežite svoj MeshCore radio, izberite ime in uporabite radijski prednastavljeni profil, preden nadaljujete.'; + + @override + String get wizardSetupBadge => 'Nastavitev'; + + @override + String get wizardOverviewBadge => 'Pregled'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Povezano z: $deviceName'; + } + + @override + String get wizardNoDeviceConnected => 'Nobena naprava še ni povezana'; + + @override + String get wizardSkipForNow => 'Za zdaj preskoči'; + + @override + String get wizardDeviceNameLabel => 'Ime naprave'; + + @override + String get wizardDeviceNameHelp => + 'To ime se oglašuje drugim uporabnikom MeshCore.'; + + @override + String get wizardConfigRegionLabel => 'Konfiguracijska regija'; + + @override + String get wizardConfigRegionHelp => + 'Uporabi celoten uradni seznam prednastavitev MeshCore. Privzeto je izbran EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Prepričajte se, da izbrana prednastavitev ustreza lokalnim radijskim predpisom.'; + + @override + String get wizardPresetNote2 => + 'Seznam ustreza uradnemu viru prednastavitev orodja MeshCore config.'; + + @override + String get wizardPresetNote3 => + 'Za uvajanje ostane privzeto izbran EU/UK (Narrow).'; + + @override + String get wizardSaving => 'Shranjujem...'; + + @override + String get wizardSaveAndContinue => 'Shrani in nadaljuj'; + + @override + String get wizardEnterDeviceName => 'Pred nadaljevanjem vnesite ime naprave.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return 'Shranjeno $deviceName z nastavitvijo $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Stiki, sobe in repetitorji'; + + @override + String get wizardNetworkDescription => + 'Zavihek Stiki organizira omrežje, ki ga odkrijete, in poti, ki se jih aplikacija nauči skozi čas.'; + + @override + String get wizardNetworkFeature1 => + 'Na enem seznamu preglejte člane ekipe, repetitorje, sobe, kanale in čakajoče oglase.'; + + @override + String get wizardNetworkFeature2 => + 'Uporabite smart ping, prijavo v sobe, naučene poti in ponastavitev poti, ko povezljivost postane neurejena.'; + + @override + String get wizardNetworkFeature3 => + 'Ustvarjajte kanale in upravljajte omrežne cilje, ne da bi zapustili aplikacijo.'; + + @override + String get wizardMapOpsTitle => 'Zemljevid, sledi in deljena geometrija'; + + @override + String get wizardMapOpsDescription => + 'Zemljevid v aplikaciji je neposredno povezan s sporočanjem, sledenjem in SAR prekrivanji, namesto da bi bil le ločen pregledovalnik.'; + + @override + String get wizardMapOpsFeature1 => + 'Spremljajte svoj položaj, lokacije sotekmovalcev in gibanje sledi na zemljevidu.'; + + @override + String get wizardMapOpsFeature2 => + 'Odprite risbe iz sporočil, jih predoglejte v vrstici in jih po potrebi odstranite z zemljevida.'; + + @override + String get wizardMapOpsFeature3 => + 'Uporabite poglede repetitorjev in deljena prekrivanja za razumevanje dosega omrežja na terenu.'; + + @override + String get wizardToolsTitle => 'Orodja onkraj sporočanja'; + + @override + String get wizardToolsDescription => + 'Tu je več kot le štirje glavni zavihki. Aplikacija vključuje tudi konfiguracijo, diagnostiko in neobvezne senzorske poteke.'; + + @override + String get wizardToolsFeature1 => + 'Odprite nastavitve naprave za spremembo radijskih nastavitev, telemetrije, oddajne moči in podatkov spremljevalne naprave.'; + + @override + String get wizardToolsFeature2 => + 'Omogočite zavihek Senzorji, kadar želite nadzorne plošče in hitra osveževanja spremljanih senzorjev.'; + + @override + String get wizardToolsFeature3 => + 'Uporabite dnevnik paketov, pregled spektra in razvojno diagnostiko pri odpravljanju težav v mesh omrežju.'; + + @override + String get postConnectDiscoveryTitle => 'Odkriti repetitorje zdaj?'; + + @override + String get postConnectDiscoveryDescription => + 'Zaženite odkrivanje repetitorjev takoj po povezavi, da vidite bližnja vozlišča MeshCore in jih hitreje dodate v svoje omrežje.'; + + @override + String get contactInSensors => 'V senzorjih'; + + @override + String get contactAddToSensors => 'Dodaj v senzorje'; + + @override + String get contactSetPath => 'Nastavi pot'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName dodan v senzorje'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Brisanje poti ni uspelo: $error'; + } + + @override + String get contactRouteCleared => 'Pot izbrisana'; + + @override + String contactRouteSet(String route) { + return 'Pot nastavljena: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Nastavitev poti ni uspela: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'Časovna omejitev ACK'; + + @override + String get opcode => 'Opkoda'; + + @override + String get payload => 'Vsebina'; + + @override + String get hops => 'Skoki'; + + @override + String get hashSize => 'Velikost zgoščevalne vrednosti'; + + @override + String get pathBytes => 'Bajti poti'; + + @override + String get selectedPath => 'Izbrana pot'; + + @override + String get estimatedTx => 'Ocenjeni čas oddaje'; + + @override + String get senderToReceipt => 'Od pošiljatelja do prejema'; + + @override + String get receivedCopies => 'Prejete kopije'; + + @override + String get retryCause => 'Razlog ponovitve'; + + @override + String get retryMode => 'Način ponovitve'; + + @override + String get retryResult => 'Rezultat ponovitve'; + + @override + String get lastRetry => 'Zadnja ponovitev'; + + @override + String get rxPackets => 'RX paketi'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Hitrost'; + + @override + String get window => 'Okno'; + + @override + String get posttxDelay => 'Zakasnitev po oddaji'; + + @override + String get bandpass => 'Pasovno sito'; + + @override + String get bandpassFilterVoice => 'Pasovno sito za glas'; + + @override + String get active => 'Aktivno'; + + @override + String get addContact => 'Dodaj stik'; + + @override + String get all => 'Vse'; + + @override + String get clearAllLabel => 'Počisti vse'; + + @override + String get clearFilters => 'Počisti filtre'; + + @override + String get clearRoute => 'Počisti pot'; + + @override + String get clearMessages => 'Počisti sporočila'; + + @override + String get clearScale => 'Počisti merilo'; + + @override + String get clearDiscoveries => 'Počisti odkritja'; + + @override + String get clearOnlineTraceDatabase => 'Počisti bazo sledi'; + + @override + String get clearAllChannels => 'Počisti vse kanale'; + + @override + String get clearAllContacts => 'Počisti vse stike'; + + @override + String get clearChannels => 'Počisti kanale'; + + @override + String get clearContacts => 'Počisti stike'; + + @override + String get clearPathOnMaxRetry => 'Počisti pot ob maks. ponovitvi'; + + @override + String get create => 'Ustvari'; + + @override + String get custom => 'Po meri'; + + @override + String get defaultValue => 'Privzeto'; + + @override + String get duplicate => 'Podvoji'; + + @override + String get editName => 'Uredi ime'; + + @override + String get open => 'Odpri'; + + @override + String get paste => 'Prilepi'; + + @override + String get preview => 'Predogled'; + + @override + String get remove => 'Odstrani'; + + @override + String get rename => 'Preimenuj'; + + @override + String get resolveAll => 'Razreši vse'; + + @override + String get send => 'Pošlji'; + + @override + String get sendAnyway => 'Vseeno pošlji'; + + @override + String get share => 'Deli'; + + @override + String get trace => 'Sledenje'; + + @override + String get discovery => 'Odkrivanje'; + + @override + String get discoverRepeaters => 'Odkrij posrednike'; + + @override + String get discoverSensors => 'Odkrij senzorje'; + + @override + String get repeaterDiscoverySent => 'Odkrivanje posrednikov poslano'; + + @override + String get sensorDiscoverySent => 'Odkrivanje senzorjev poslano'; + + @override + String get clearedPendingDiscoveries => 'Čakalna odkritja počiščena.'; + + @override + String get autoDiscovery => 'Samodejno odkrivanje'; + + @override + String get enableAutomaticAdding => 'Omogoči samodejno dodajanje'; + + @override + String get autoaddRepeaters => 'Samodejno dodaj posrednike'; + + @override + String get autoaddRoomServers => 'Samodejno dodaj strežnike sob'; + + @override + String get autoaddSensors => 'Samodejno dodaj senzorje'; + + @override + String get autoaddUsers => 'Samodejno dodaj uporabnike'; + + @override + String get overwriteOldestWhenFull => 'Prepiši najstarejše ob polnem'; + + @override + String get storage => 'Shramba'; + + @override + String get dangerZone => 'Nevarno območje'; + + @override + String get profiles => 'Profili'; + + @override + String get favourites => 'Priljubljeni'; + + @override + String get sensors => 'Senzorji'; + + @override + String get others => 'Ostali'; + + @override + String get gpsModule => 'GPS modul'; + + @override + String get liveTraffic => 'Promet v živo'; + + @override + String get repeatersMap => 'Zemljevid posrednikov'; + + @override + String get blePacketLogs => 'Dnevniki BLE paketov'; + + @override + String get onlineTraceDatabase => 'Baza sledi'; + + @override + String get routePathByteSize => 'Velikost poti v bajtih'; + + @override + String get messageNotifications => 'Obvestila o sporočilih'; + + @override + String get sarAlerts => 'SAR opozorila'; + + @override + String get discoveryNotifications => 'Obvestila o odkritjih'; + + @override + String get updateNotifications => 'Obvestila o posodobitvah'; + + @override + String get muteWhileAppIsOpen => 'Utišaj ko je aplikacija odprta'; + + @override + String get disableContacts => 'Onemogoči stike'; + + @override + String get enableSensorsTab => 'Omogoči zavihek Senzorji'; + + @override + String get enableProfiles => 'Omogoči profile'; + + @override + String get nearestRepeaterFallback => 'Najbližji posrednik kot rezerva'; + + @override + String get deleteAllStoredMessageHistory => + 'Izbriši vso shranjeno zgodovino sporočil'; + + @override + String get messageFontSize => 'Velikost pisave sporočil'; + + @override + String get rotateMapWithHeading => 'Vrti zemljevid s smerjo'; + + @override + String get showMapDebugInfo => 'Prikaži razhroščevalne info zemljevida'; + + @override + String get openMapInFullscreen => 'Odpri zemljevid na cel zaslon'; + + @override + String get showSarMarkersLabel => 'Prikaži SAR oznake'; + + @override + String get hideRepeatersOnMap => 'Skrij posrednike na zemljevidu'; + + @override + String get setMapScale => 'Nastavi merilo zemljevida'; + + @override + String get customMapScaleSaved => 'Merilo zemljevida shranjeno'; + + @override + String get voiceBitrate => 'Bitna hitrost glasu'; + + @override + String get voiceCompressor => 'Kompresor glasu'; + + @override + String get voiceLimiter => 'Omejevalnik glasu'; + + @override + String get micAutoGain => 'Samodejna ojačitev mikrofona'; + + @override + String get echoCancellation => 'Odstranjevanje odmeva'; + + @override + String get noiseSuppression => 'Dušenje šuma'; + + @override + String get trimSilenceInVoiceMessages => + 'Obreži tišino v glasovnih sporočilih'; + + @override + String get compressor => 'Kompresor'; + + @override + String get limiter => 'Omejevalnik'; + + @override + String get autoGain => 'Samodejna ojačitev'; + + @override + String get echoCancel => 'Odmev'; + + @override + String get noiseSuppress => 'Šum'; + + @override + String get silenceTrim => 'Tišina'; + + @override + String get maxImageSize => 'Največja velikost slike'; + + @override + String get imageCompression => 'Stiskanje slik'; + + @override + String get grayscale => 'Sivine'; + + @override + String get ultraMode => 'Ultra način'; + + @override + String get fastPrivateGpsUpdates => 'Hitri zasebni GPS podatki'; + + @override + String get movementThreshold => 'Prag gibanja'; + + @override + String get fastGpsMovementThreshold => 'Prag gibanja za hitri GPS'; + + @override + String get fastGpsActiveuseInterval => 'Interval uporabe hitrega GPS-a'; + + @override + String get activeuseUpdateInterval => + 'Interval posodobitve pri aktivni uporabi'; + + @override + String get repeatNearbyTraffic => 'Ponavljaj bližnji promet'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Posreduj skozi posrednike po mreži'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Samo bližnji, brez poplavljanja posrednikov'; + + @override + String get multihop => 'Večskočno'; + + @override + String get createProfile => 'Ustvari profil'; + + @override + String get renameProfile => 'Preimenuj profil'; + + @override + String get newProfile => 'Nov profil'; + + @override + String get manageProfiles => 'Upravljaj profile'; + + @override + String get enableProfilesToStartManagingThem => + 'Omogočite profile, da jih začnete upravljati.'; + + @override + String get openMessage => 'Odpri sporočilo'; + + @override + String get jumpToTheRelatedSarMessage => 'Skoči na povezano SAR sporočilo'; + + @override + String get removeSarMarker => 'Odstrani SAR oznako'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Izberite cilj za pošiljanje SAR oznake'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'SAR oznaka poslana na javni kanal'; + + @override + String get sarMarkerSentToRoom => 'SAR oznaka poslana v sobo'; + + @override + String get loadFromGallery => 'Naloži iz galerije'; + + @override + String get replaceImage => 'Zamenjaj sliko'; + + @override + String get selectFromGallery => 'Izberi iz galerije'; + + @override + String get team => 'Ekipa'; + + @override + String get found => 'Najdeno'; + + @override + String get staging => 'Zbirno mesto'; + + @override + String get object => 'Predmet'; + + @override + String get quiet => 'Tiho'; + + @override + String get busy => 'Zasedeno'; + + @override + String get searchMessages => 'Iskanje sporočil'; + + @override + String get sendImageFromGallery => 'Pošlji sliko iz galerije'; + + @override + String get takePhoto => 'Posnemi fotografijo'; + + @override + String get allMessages => 'Vsa sporočila'; + + @override + String get sendToPublicChannel => 'Pošlji na javni kanal?'; + + @override + String get selectMarkerTypeAndDestination => 'Izberite vrsto oznake in cilj'; + + @override + String get noDestinationsAvailableLabel => 'Ni razpoložljivih ciljev'; + + @override + String get image => 'Slika'; + + @override + String get format => 'Format'; + + @override + String get dimensions => 'Dimenzije'; + + @override + String get segments => 'Segmenti'; + + @override + String get transfers => 'Prenosi'; + + @override + String get downloadedBy => 'Preneseno od'; + + @override + String get saveDiscoverySettings => 'Shrani nastavitve odkrivanja'; + + @override + String get savePublicInfo => 'Shrani javne podatke'; + + @override + String get saveRadioSettings => 'Shrani radijske nastavitve'; + + @override + String get savePath => 'Shrani pot'; + + @override + String get wipeDeviceData => 'Izbriši podatke naprave'; + + @override + String get wipeDevice => 'Izbriši napravo'; + + @override + String get destructiveDeviceActions => 'Destruktivna dejanja na napravi.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Izberite prednastavitev ali natančno prilagodite radijske nastavitve.'; + + @override + String get used => 'Uporabljeno'; + + @override + String get total => 'Skupaj'; + + @override + String get renameValue => 'Preimenuj vrednost'; + + @override + String get customizeFields => 'Prilagodi polja'; + + @override + String get livePreview => 'Predogled v živo'; + + @override + String get refreshSchedule => 'Interval osvežitve'; + + @override + String get noResponse => 'Ni odgovora'; + + @override + String get refreshing => 'Osvežujem'; + + @override + String get unavailable => 'Nedostopno'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Izberite posrednika ali vozlišče za opazovanje.'; + + @override + String get publicKeyLabel => 'Javni ključ'; + + @override + String get alreadyInContacts => 'Že med stiki'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Povežite se z napravo pred dodajanjem stikov'; + + @override + String get fromContacts => 'Iz stikov'; + + @override + String get onlineOnly => 'Samo povezani'; + + @override + String get inBoth => 'V obeh'; + + @override + String get source => 'Vir'; + + @override + String get allMessagesCleared => 'Vsa sporočila počiščena'; + + @override + String get onlineTraceDatabaseCleared => 'Baza sledi počiščena'; + + @override + String get packetLogsCleared => 'Dnevniki paketov počiščeni'; + + @override + String get hexDataCopiedToClipboard => 'Hex podatki kopirani v odložišče'; + + @override + String get developerModeEnabled => 'Razvojni način omogočen'; + + @override + String get developerModeDisabled => 'Razvojni način onemogočen'; + + @override + String get clipboardIsEmpty => 'Odložišče je prazno'; + + @override + String get contactImported => 'Stik uvožen'; + + @override + String get contactLinkCopiedToClipboard => + 'Povezava na stik kopirana v odložišče'; + + @override + String get failedToExportContact => 'Izvoz stika ni uspel'; + + @override + String get noLogsToExport => 'Ni dnevnikov za izvoz'; + + @override + String get exportAsCsv => 'Izvozi kot CSV'; + + @override + String get exportAsText => 'Izvozi kot besedilo'; + + @override + String get receivedRfc3339 => 'Prejeto (RFC3339)'; + + @override + String get buildTime => 'Čas gradnje'; + + @override + String get downloadUrlNotAvailable => 'URL za prenos ni na voljo'; + + @override + String get cannotOpenDownloadUrl => 'URL za prenos ni mogoče odpreti'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'Preverjanje posodobitev je na voljo le na Androidu'; + + @override + String get youAreRunningTheLatestVersion => + 'Uporabljate najnovejšo različico'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Posodobitev na voljo, a URL za prenos ni najden'; + + @override + String get startTictactoe => 'Začni Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe ni na voljo'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: nasprotnik neznan'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: čakanje na začetek'; + + @override + String get acceptsShareLinks => 'Sprejema deljene povezave'; + + @override + String get supportsRawHex => 'Podpira surove hex'; + + @override + String get clipboardfriendly => 'Primerno za odložišče'; + + @override + String get captured => 'Zajeto'; + + @override + String get size => 'Velikost'; + + @override + String get noCustomChannelsToClear => 'Ni prilagojenih kanalov za brisanje.'; + + @override + String get noDeviceContactsToClear => 'Ni stikov naprave za brisanje.'; + + @override + String get aToZ => 'A-Ž'; + + @override + String get add => 'Dodaj'; + + @override + String get addAdditionalDetails => 'Dodaj dodatne podrobnosti'; + + @override + String get addPeer => 'Dodaj soležnika'; + + @override + String get addPeerManually => 'Ročno dodaj soležnika'; + + @override + String addedToContacts(String name) { + return '$name dodan med stike'; + } + + @override + String addedToSensors(String name) { + return '$name dodan med senzorje'; + } + + @override + String get allowAll => 'Dovoli vse'; + + @override + String get anonymousRxStats => 'Anonimna RX statistika'; + + @override + String get appearance => 'Videz'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Osnovna telemetrija'; + + @override + String get blePin => 'BLE PIN'; + + @override + String get buzzerAlerts => 'Zvočni alarmi'; + + @override + String get cancelImageReceive => 'Prekliči prejem slike'; + + @override + String get choose => 'Izberi'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Izberite, katere zavihke in razdelke stikov prikazati'; + + @override + String get clearCache => 'Počisti predpomnilnik'; + + @override + String get clearHistory => 'Počisti zgodovino'; + + @override + String get clearLiveView => 'Počisti pogled v živo'; + + @override + String get clearLogs => 'Počisti dnevnike'; + + @override + String get clearOfflineCache => 'Počisti predpomnilnik brez povezave?'; + + @override + String get clearOverlay => 'Počisti prekrivanje'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Počisti pot šele po neuspehu vseh ponovitev'; + + @override + String get codecBitrateAndAudioProcessing => + 'Kodek, bitna hitrost in obdelava zvoka'; + + @override + String get connectByIpAddress => 'Poveži prek IP naslova'; + + @override + String get controlWhichAlertsYouReceive => + 'Nadzirajte, katera opozorila prejemate'; + + @override + String get copyFullHex => 'Kopiraj celoten Hex'; + + @override + String get copyRawResponse => 'Kopiraj surov odgovor'; + + @override + String get couldNotLoadMetHistory => 'Nalaganje MET zgodovine ni uspelo'; + + @override + String customizeSensor(String name) { + return 'Prilagodi $name'; + } + + @override + String get data => 'Podatki'; + + @override + String get deleteGroup => 'Izbriši skupino'; + + @override + String deleteStyleConfirm(String name) { + return 'Izbriši $name?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Izbriši to $type?'; + } + + @override + String get deny => 'Zavrni'; + + @override + String get deviceInfo => 'Informacije o napravi'; + + @override + String get deviceSettings => 'Nastavitve naprave'; + + @override + String get deviceTimeSynced => 'Čas naprave sinhroniziran s tem telefonom.'; + + @override + String get disableFastGpsPublishing => 'Onemogoči hitro GPS objavljanje'; + + @override + String get displayMarkersAndTraceDatabase => + 'Prikaz, oznake in baza sledenja'; + + @override + String get download => 'Prenesi'; + + @override + String get environmentalTelemetry => 'Okoljska telemetrija'; + + @override + String errorCheckingForUpdates(String error) { + return 'Napaka pri preverjanju posodobitev: $error'; + } + + @override + String errorGeneric(String error) { + return 'Napaka: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Napaka pri odpiranju prenosa: $error'; + } + + @override + String get exportLogs => 'Izvozi dnevnike'; + + @override + String get extraAggressiveAvifCompression => + 'Posebno agresivno AVIF stiskanje'; + + @override + String failedToAddContact(String error) { + return 'Dodajanje stika ni uspelo: $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Dodajanje $name ni uspelo: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Brisanje kanalov ni uspelo: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Brisanje stikov ni uspelo: $error'; + } + + @override + String get failedToConnectViaSerial => + 'Povezava prek serijske povezave ni uspela'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Nalaganje predogleda slike ni uspelo: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Nalaganje sledi ni uspelo: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Pošiljanje SAR oznake ni uspelo: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Nastavitev zvočnega alarma ni uspela: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Nastavitev GPS načina ni uspela: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Sinhronizacija časa naprave ni uspela: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Brisanje podatkov naprave ni uspelo: $error'; + } + + @override + String get fastGpsTargetChannel => 'Ciljni kanal hitrega GPS-a'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Hitre posodobitve, pragovi in dovoljenja'; + + @override + String get fetchingCatalog => 'Pridobivanje kataloga...'; + + @override + String get fifteenMinutes => '15 minut'; + + @override + String get fifteenSeconds => '15 sekund'; + + @override + String get filterByDirection => 'Filtriraj po smeri'; + + @override + String get finish => 'Končaj'; + + @override + String get fiveMinutes => '5 minut'; + + @override + String get fiveSeconds => '5 sekund'; + + @override + String get fix => 'Popravi'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'Politika GPS oglašanja'; + + @override + String get gpsAndLocation => 'GPS in lokacija'; + + @override + String get gpsDiagnostics => 'GPS diagnostika'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Pomoč'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Skrito'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Kako radio samodejno dodaja odkrita vozlišča.'; + + @override + String get images => 'Slike'; + + @override + String get importProfile => 'Uvozi profil'; + + @override + String get lastFix => 'Zadnja posneta lokacija'; + + @override + String get latest => 'Najnovejše'; + + @override + String get latitude => 'Geografska širina'; + + @override + String get loadASavedRegion => 'Naloži shranjeno regijo'; + + @override + String get loading => 'Nalaganje'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Deljenje lokacije, strojna oprema in interval posodobitev.'; + + @override + String get locationTelemetry => 'Telemetrija lokacije'; + + @override + String get lockDestination => 'Zakleni cilj'; + + @override + String get longitude => 'Geografska dolžina'; + + @override + String get mapStyle => 'Slog zemljevida'; + + @override + String get max => 'Maks'; + + @override + String get maxZoom => 'Maks. povečava'; + + @override + String get messaging => 'Sporočanje'; + + @override + String get metHistory => 'MET zgodovina'; + + @override + String get min => 'Min'; + + @override + String get minZoom => 'Min. povečava'; + + @override + String get moveDown => 'Premakni navzdol'; + + @override + String get moveUp => 'Premakni navzgor'; + + @override + String get multiAckMode => 'Način večkratne ACK potrditve'; + + @override + String get multiDeviceWorkspaceManagement => + 'Upravljanje delovnega prostora z več napravami'; + + @override + String get myLocation => 'Moja lokacija'; + + @override + String get nameAndTelemetryShared => + 'Ime in telemetrija deljena z drugimi napravami.'; + + @override + String newVersionAvailable(String version) { + return 'Nova različica: $version'; + } + + @override + String get newer => 'Novejše'; + + @override + String get noNeighboursFound => 'Ni najdenih sosedov'; + + @override + String get noServersFound => 'Ni najdenih strežnikov'; + + @override + String get notSet => 'Ni nastavljeno'; + + @override + String get notifications => 'Obvestila'; + + @override + String get offZeroSeconds => 'Izključeno (0 s)'; + + @override + String get offlineMaps => 'Zemljevidi brez povezave'; + + @override + String get older => 'Starejše'; + + @override + String get oneByteMode0 => '1 bajt (način 0)'; + + @override + String get oneHour => '1 ura'; + + @override + String get oneMinute => '1 minuta'; + + @override + String get openPacketLogs => 'Odpri dnevnike paketov'; + + @override + String get packetTypeHelp => 'Pomoč za vrsto paketa'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Prilepite deljeno povezavo ali Hex oglas'; + + @override + String get pathHash => 'Zgoščevalna vrednost poti'; + + @override + String get pathSize => 'Velikost poti'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Poligon'; + + @override + String get rawResponseCopied => 'Surov odgovor kopiran'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Ponovno pošlji prek najbližjega posrednika ob neuspehu'; + + @override + String get resolveContact => 'Razreši stik'; + + @override + String get routingRetriesAndDestinationLock => + 'Usmerjanje, ponovitve in zaklepanje cilja'; + + @override + String get samples => 'Vzorci'; + + @override + String get satellites => 'Sateliti'; + + @override + String get saving => 'Shranjujem...'; + + @override + String get search => 'Iskanje'; + + @override + String get searchByNameOrToken => 'Iskanje po imenu ali žetonu'; + + @override + String get searchDiscoveredNodes => 'Iskanje odkritih vozlišč'; + + @override + String get searchInCurrentFilter => 'Iskanje v trenutnem filtru'; + + @override + String get searchLogs => 'Iskanje dnevnikov...'; + + @override + String get searchSensors => 'Iskanje senzorjev'; + + @override + String get searchThisSection => 'Iskanje v tem razdelku'; + + @override + String get selectedRelays => 'Izbrani posredniki'; + + @override + String get sensor => 'Senzor'; + + @override + String serialError(String error) { + return 'Napaka serijske povezave: $error'; + } + + @override + String setPathForContact(String name) { + return 'Nastavi pot za $name'; + } + + @override + String get shareLiveGps => 'Deli GPS v živo'; + + @override + String get shareMyTiles => 'Deli moje ploščice'; + + @override + String get sharingLocationFromPhone => + 'Deljenje lokacije na tem kanalu s telefona.'; + + @override + String get sharingLocationFromRadio => + 'Deljenje lokacije na tem kanalu z radia.'; + + @override + String signalDbm(String rssi) { + return 'Signal $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => 'Velikost, stiskanje in predogled'; + + @override + String get simpleMode => 'Enostavni način'; + + @override + String get simpleModeDescription => + 'Samo klepet in zemljevid s priljubljenimi stiki'; + + @override + String get simpleModeRequiresLockedDestination => + 'Za uporabo enostavnega načina najprej omogočite »Zakleni cilj« s kanalom'; + + @override + String get noFavouriteContacts => 'Ni priljubljenih stikov'; + + @override + String get skip => 'Preskoči'; + + @override + String get smallerFileSize => 'Manjša velikost datoteke'; + + @override + String get sort => 'Razvrsti'; + + @override + String get stoppedSharingLocation => + 'Deljenje lokacije na tem kanalu ustavljeno.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Zadrži obvestila, ko je aplikacija v ospredju'; + + @override + String get tabsAndNavigation => 'Zavihki in navigacija'; + + @override + String get templatesAndTutorials => 'Predloge in vadnice'; + + @override + String get tenMinutes => '10 minut'; + + @override + String get tenSeconds => '10 sekund'; + + @override + String get testSendUpdate => 'Preizkusno pošlji posodobitev'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Tema, jezik in nastavitve prikaza'; + + @override + String get thirtyMinutes => '30 minut'; + + @override + String get thirtySeconds => '30 sekund'; + + @override + String get threeBytesMode2 => '3 bajti (način 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Statistika prometa, zgodovina sporočil in razvojna orodja'; + + @override + String get twoBytesMode1 => '2 bajta (način 1)'; + + @override + String get twoMinutes => '2 minuti'; + + @override + String get undo => 'Razveljavi'; + + @override + String get updated => 'Posodobljeno'; + + @override + String get useContactFlags => 'Uporabi zastavice stikov'; + + @override + String get useSavedCoordinates => 'Uporabi shranjene koordinate'; + + @override + String get view => 'Pogled'; + + @override + String get viewNeighbours => 'Poglej sosede'; + + @override + String get viewPublicStats => 'Poglej javno statistiko'; + + @override + String get setRegionScope => 'Nastavi obseg regije'; + + @override + String get regionScope => 'Obseg regije'; + + @override + String get regionScopeNone => 'Brez (globalno)'; + + @override + String get clearRegionScope => 'Počisti obseg'; + + @override + String get regionScopeWarning => + 'Samo repetitorji, ki dovoljujejo to regijo, bodo posredovali.'; + + @override + String get discoverRegions => 'Odkrij iz repetitorjev'; + + @override + String get discoveringRegions => 'Odkrivanje regij...'; + + @override + String get enterRegionName => 'Ime regije (npr. auckland)'; + + @override + String get noRegionsFound => 'Na tem repetitorju ni najdenih regij.'; + + @override + String regionScopeSet(String name) { + return 'Obseg regije nastavljen na $name'; + } + + @override + String get regionScopeCleared => 'Obseg regije počiščen'; + + @override + String get selectDestinationFirst => 'Najprej izberite kanal, stik ali sobo'; + + @override + String get recipientNoLongerAvailable => 'Prejemnik ni več na voljo'; + + @override + String failedToSendMessage(String error) { + return 'Pošiljanje ni uspelo: $error'; + } + + @override + String get tictactoeDirectOnly => + 'Križci in krožci delujejo samo v neposrednih sporočilih. Najprej izberite stik.'; + + @override + String get deviceKeyUnavailable => 'Ključ naprave ni na voljo'; + + @override + String get imageCompressionFailed => 'Stiskanje slike ni uspelo'; + + @override + String get imageFragmentationFailed => 'Razdelitev slike ni uspela'; + + @override + String get failedToAnnounceImage => 'Najava slike ni uspela'; + + @override + String get noRecipientSelected => 'Prejemnik ni izbran'; + + @override + String get imageSendFailed => 'Pošiljanje slike ni uspelo'; + + @override + String get microphonePermissionRequiredForVoice => + 'Za glasovna sporočila je potrebno dovoljenje za mikrofon'; + + @override + String get recordingDiscarded => 'Posnetek je zavržen'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Pravkar boste poslali $mediaType v javni kanal. To ni priporočljivo, ker lahko vsebino prejmejo vsi v omrežju mesh. Izberite zaseben ali označen kanal, razen če to res želite.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Za deljenje lokacije izberite zaseben kanal.'; + + @override + String get stopSharingMyLocation => 'Prenehaj deliti mojo lokacijo'; + + @override + String get shareMyLocation => 'Deli mojo lokacijo'; + + @override + String get stopRecording => 'Ustavi snemanje'; + + @override + String get recordVoice => 'Posnemi glasovno sporočilo'; + + @override + String get moreActions => 'Več dejanj'; + + @override + String get noMessagesInFilter => 'V tem filtru ni sporočil'; + + @override + String get noMatchesInFilter => 'V tem filtru ni zadetkov'; + + @override + String syncFailed(String error) { + return 'Sinhronizacija ni uspela: $error'; + } + + @override + String get invalidLink => 'Neveljavna povezava'; + + @override + String get cannotOpenLink => 'Povezave ni mogoče odpreti'; + + @override + String get failedToOpenLink => 'Odpiranje povezave ni uspelo'; + + @override + String get failedToResendMessage => 'Ponovno pošiljanje sporočila ni uspelo'; + + @override + String retryFailed(String error) { + return 'Ponovni poskus ni uspel: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Izbriši risbo in sporočilo'; + + @override + String get lastEchoRelay => 'Posrednik zadnjega odmeva'; + + @override + String get lastEchoPath => 'Pot zadnjega odmeva'; + + @override + String get lastEchoBytesReport => 'Poročilo o bajtih zadnjega odmeva'; + + @override + String get noGpsCoordinatesAvailable => 'GPS-koordinate niso na voljo'; + + @override + String get notASarMarker => 'To ni SAR označevalec'; + + @override + String failedToSaveTemplate(String error) { + return 'Shranjevanje predloge ni uspelo: $error'; + } + + @override + String get drawingNotFound => 'Risbe ni mogoče najti'; + + @override + String get unknownDrawingType => 'Neznana vrsta risbe'; + + @override + String get customMapMarker => 'Označevalec karte po meri'; + + @override + String customMapPointLabel(String point) { + return 'Točka: $point'; + } + + @override + String mapIdLabel(String id) { + return 'ID karte: $id'; + } + + @override + String get cannotFetchVoice => 'Glasovnega sporočila ni mogoče prenesti'; + + @override + String get senderContactUnknown => + 'Stik pošiljatelja ni znan. Najprej sinhronizirajte stike.'; + + @override + String get senderRouteUnknown => + 'Pot pošiljatelja ni znana. Najprej sinhronizirajte stike/poti.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Sporočilo je predaleč ($hops skokov, največ $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'Pot pošiljatelja se ni odzvala na preverjanje poti. Sinhronizirajte stike/poti in poskusite znova.'; + + @override + String get senderRouteNoRawResponse => + 'Pot pošiljatelja se ni odzvala na neposredni prenosni poti.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Prenos glasu prek $hops skokov lahko traja dlje.'; + } + + @override + String get voiceReceiveCanceled => + 'Sprejem glasovnega sporočila je preklican'; + + @override + String get fetchingMissingVoiceFragments => + 'Pridobivanje manjkajočih glasovnih delov'; + + @override + String get receivingVoice => 'Sprejemanje glasu'; + + @override + String get cannotFetchImage => 'Slike ni mogoče prenesti'; + + @override + String imageFetchOverHops(String hops) { + return 'Prenos slike prek $hops skokov lahko traja dlje.'; + } + + @override + String get imageAlreadyBeingReceived => 'Slika se že sprejema'; + + @override + String get loadImage => 'Naloži sliko'; + + @override + String get imageFetchFailedToSendRequest => + 'Zahteve za prenos slike ni bilo mogoče poslati'; + + @override + String get imageUnavailable => 'Slika trenutno ni na voljo'; + + @override + String get imageFetchTimedOut => 'Prenos slike je potekel'; + + @override + String get imageReceiveCanceled => 'Sprejem slike je preklican'; + + @override + String get closeImagePreview => 'Zapri predogled slike'; + + @override + String get recordingReleaseToSend => + 'Snemanje ... spustite za pošiljanje glasu'; + + @override + String get sendingVoice => 'Pošiljanje glasu ...'; + + @override + String get sendLongPressToRecordVoice => + 'Pošlji (dolg pritisk za snemanje glasu)'; + + @override + String get discardRecording => 'Zavrzi posnetek'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count skokov', + few: '$count skoki', + two: '$count skoka', + one: '$count skok', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Oddajni kanali za bližnji promet v omrežju mesh'; + + @override + String get roomsSectionDescription => + 'Skupni prostori za stalno usklajevanje ekipe'; + + @override + String get contactsSectionDescription => + 'Osebe in naprave, ki jih lahko dosežete neposredno'; + + @override + String get noRecentChatters => 'Ni nedavnih klepetalcev'; + + @override + String get public => 'Javno'; + + @override + String get failedToSendTicTacToeMove => + 'Poteze pri križcih in krožcih ni bilo mogoče poslati'; + + @override + String get youWon => 'Zmagali ste'; + + @override + String get opponentWon => 'Zmagal je nasprotnik'; + + @override + String get gameDraw => 'Neodločeno'; + + @override + String get yourTurn => 'Vi ste na vrsti'; + + @override + String get opponentTurn => 'Na vrsti je nasprotnik'; + + @override + String get pleaseEnterValidCoordinates => 'Vnesite veljavne koordinate'; + + @override + String get locationNotAvailable => 'Lokacija ni na voljo'; + + @override + String get customCaveMapPoint => 'Točka jamske karte po meri'; + + @override + String get mapPoint => 'Točka na karti'; + + @override + String get fetchingMissingImageFragments => + 'Pridobivanje manjkajočih delov …'; + + @override + String get loadingImage => 'Nalaganje …'; + + @override + String get receivingImage => 'Sprejemanje …'; + + @override + String get tapToLoad => 'Tapnite za nalaganje'; + + @override + String get connectDevice => 'Poveži napravo'; + + @override + String get chooseTransportSubtitle => + 'Izberite način povezave: Bluetooth, WiFi ali serijska'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Omrežje'; + + @override + String get serial => 'Serijsko'; + + @override + String get scan => 'Poišči'; + + @override + String get pressScanToSearchForDevices => + 'Pritisnite Poišči za iskanje naprav v bližini'; + + @override + String failedToConnectToDevice(String name) { + return 'Povezava z napravo $name ni uspela'; + } + + @override + String get unknownDevice => 'Neznana naprava'; + + @override + String get showingCachedResultsTapRefresh => + 'Prikazani so predpomnjeni rezultati. Za ponovno iskanje tapnite osveži.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Iskanje naprav MeshCore WiFi v lokalnem omrežju na vratih $port'; + } + + @override + String get cancelScanAndAddServer => 'Prekliči iskanje in dodaj strežnik'; + + @override + String get addServer => 'Dodaj strežnik'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Iskanje … $scanned/$total IP naslovov'; + } + + @override + String get cancelAndEnterManually => 'Prekliči in vnesi ročno'; + + @override + String get noRecentOrDiscoveredServers => + 'Še ni nedavnih ali odkritih strežnikov'; + + @override + String get recentlyUsed => 'Nedavno uporabljeno'; + + @override + String get connecting => 'Povezovanje …'; + + @override + String get discoveredOnThisNetwork => 'Odkrito v tem omrežju'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'Strežnik na $host:$port ni več dosegljiv. Ponovite iskanje, da najdete aktivne strežnike.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Povezava s strežnikom $host:$port ni uspela'; + } + + @override + String get enterValidIpAddress => 'Vnesite veljaven IP naslov'; + + @override + String get enterValidTcpPort => 'Vnesite veljavna vrata TCP'; + + @override + String get ipAddress => 'IP naslov'; + + @override + String get tcpPort => 'Vrata TCP'; + + @override + String get customServerPort => 'Vrata strežnika po meri'; + + @override + String get chat => 'Klepet'; + + @override + String get restoringPreviousLink => 'Obnavljanje prejšnje povezave'; + + @override + String get noDeviceConnected => 'Ni povezane naprave'; + + @override + String get sendAdvert => 'Pošlji moj kontakt'; + + @override + String get advertMode => 'Pošlji moj kontakt'; + + @override + String get advertModeSubtitle => + 'Izberite, kako daleč naj potujejo vaši kontaktni podatki.'; + + @override + String get floodAdvertSent => 'Kontakt poslan z razprševanjem po omrežju'; + + @override + String get directAdvertSent => 'Kontakt poslan neposredno bližnjim napravam'; +} diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart new file mode 100644 index 0000000..10353f0 --- /dev/null +++ b/lib/l10n/app_localizations_tr.dart @@ -0,0 +1,3424 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Turkish (`tr`). +class AppLocalizationsTr extends AppLocalizations { + AppLocalizationsTr([String locale = 'tr']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Mesajlar'; + + @override + String get contacts => 'Kişiler'; + + @override + String get map => 'Harita'; + + @override + String get settings => 'Ayarlar'; + + @override + String get connect => 'Bağlan'; + + @override + String get disconnect => 'Bağlantıyı kes'; + + @override + String get noDevicesFound => 'Cihaz bulunamadı'; + + @override + String get scanAgain => 'Tekrar tara'; + + @override + String get deviceNotConnected => 'Cihaz bağlı değil'; + + @override + String get locationPermissionDenied => 'Konum izni reddedildi'; + + @override + String get locationPermissionPermanentlyDenied => + 'Konum izni kalıcı olarak reddedildi. Lütfen Ayarlar bölümünden etkinleştirin.'; + + @override + String get locationPermissionRequired => + 'GPS takibi ve ekip koordinasyonu için konum izni gereklidir. Daha sonra Ayarlar bölümünden etkinleştirebilirsiniz.'; + + @override + String get locationServicesDisabled => + 'Konum servisleri kapalı. Lütfen Ayarlar bölümünden etkinleştirin.'; + + @override + String get failedToGetGpsLocation => 'GPS konumu alınamadı'; + + @override + String failedToAdvertise(String error) { + return 'Yayın başarısız: $error'; + } + + @override + String get cancelReconnection => 'Yeniden bağlanmayı iptal et'; + + @override + String get general => 'Genel'; + + @override + String get theme => 'Tema'; + + @override + String get chooseTheme => 'Tema seç'; + + @override + String get light => 'Açık'; + + @override + String get dark => 'Koyu'; + + @override + String get blueLightTheme => 'Mavi açık tema'; + + @override + String get blueDarkTheme => 'Mavi koyu tema'; + + @override + String get sarRed => 'SAR Kırmızı'; + + @override + String get alertEmergencyMode => 'Uyarı/Acil durum modu'; + + @override + String get sarGreen => 'SAR Yeşil'; + + @override + String get safeAllClearMode => 'Güvenli/Tamam modu'; + + @override + String get autoSystem => 'Otomatik (Sistem)'; + + @override + String get followSystemTheme => 'Sistem temasını takip et'; + + @override + String get showRxTxIndicators => 'RX/TX göstergelerini göster'; + + @override + String get disableMap => 'Haritayı devre dışı bırak'; + + @override + String get language => 'Dil'; + + @override + String get chooseLanguage => 'Dil seç'; + + @override + String get save => 'Kaydet'; + + @override + String get cancel => 'İptal'; + + @override + String get close => 'Kapat'; + + @override + String get about => 'Hakkında'; + + @override + String get appVersion => 'Uygulama sürümü'; + + @override + String get aboutMeshCoreSar => 'MeshCore SAR hakkında'; + + @override + String get aboutDescription => + 'Acil durum müdahale ekipleri için tasarlanmış bir Arama ve Kurtarma uygulaması. Özellikler:\n\n• Cihazdan cihaza iletişim için BLE mesh ağı\n• Birden çok katman seçeneğiyle çevrimdışı haritalar\n• Ekip üyelerinin gerçek zamanlı takibi\n• SAR taktik işaretleri (bulunan kişi, yangın, toplanma alanı)\n• Kişi yönetimi ve mesajlaşma\n• Pusula yönüyle GPS takibi\n• Çevrimdışı kullanım için harita karo önbelleği'; + + @override + String get technologiesUsed => 'Kullanılan teknolojiler:'; + + @override + String get technologiesList => + '• Çok platformlu geliştirme için Flutter\n• Mesh ağı için BLE (Bluetooth Low Energy)\n• Haritalama için OpenStreetMap\n• Durum yönetimi için Provider\n• Yerel depolama için SharedPreferences'; + + @override + String get moreInfo => 'Daha fazla bilgi'; + + @override + String get packageName => 'Paket adı'; + + @override + String get sampleData => 'Örnek veriler'; + + @override + String get sampleDataDescription => + 'Test için örnek kişiler, kanal mesajları ve SAR işaretleri yükleyin veya temizleyin'; + + @override + String get loadSampleData => 'Örnek verileri yükle'; + + @override + String get clearAllData => 'Tüm verileri temizle'; + + @override + String get clearAllDataConfirmTitle => 'Tüm verileri temizle'; + + @override + String get clearAllDataConfirmMessage => + 'Bu işlem tüm kişileri ve SAR işaretlerini temizleyecek. Emin misiniz?'; + + @override + String get clear => 'Temizle'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return '$teamCount ekip üyesi, $channelCount kanal, $sarCount SAR işareti, $messageCount mesaj yüklendi'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Örnek veriler yüklenemedi: $error'; + } + + @override + String get allDataCleared => 'Tüm veriler temizlendi'; + + @override + String get failedToStartBackgroundTracking => + 'Arka plan takibi başlatılamadı. İzinleri ve BLE bağlantısını kontrol edin.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Konum yayını: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'Ekranı olmayan cihazlar için varsayılan PIN 123456’dır. Eşleştirme sorunu mu yaşıyorsunuz? Sistem ayarlarından Bluetooth cihazını unutun.'; + + @override + String get noMessagesYet => 'Henüz mesaj yok'; + + @override + String get pullDownToSync => 'Mesajları senkronize etmek için aşağı çekin'; + + @override + String get deleteContact => 'Kişiyi sil'; + + @override + String get delete => 'Sil'; + + @override + String get viewOnMap => 'Haritada görüntüle'; + + @override + String get refresh => 'Yenile'; + + @override + String get resetPath => 'Yolu sıfırla (yeniden yönlendir)'; + + @override + String copiedToClipboard(String label) { + return '$label panoya kopyalandı'; + } + + @override + String get pleaseEnterPassword => 'Lütfen bir parola girin'; + + @override + String failedToSyncContacts(String error) { + return 'Kişiler senkronize edilemedi: $error'; + } + + @override + String get loggedInSuccessfully => + 'Giriş başarılı! Oda mesajları bekleniyor...'; + + @override + String get loginFailed => 'Giriş başarısız - yanlış parola'; + + @override + String loggingIn(String roomName) { + return '$roomName odasına giriş yapılıyor...'; + } + + @override + String failedToSendLogin(String error) { + return 'Giriş gönderilemedi: $error'; + } + + @override + String get lowLocationAccuracy => 'Düşük konum doğruluğu'; + + @override + String get continue_ => 'Devam et'; + + @override + String get sendSarMarker => 'SAR işareti gönder'; + + @override + String get deleteDrawing => 'Çizimi sil'; + + @override + String get drawingTools => 'Çizim araçları'; + + @override + String get drawLine => 'Çizgi çiz'; + + @override + String get drawLineDesc => 'Harita üzerinde serbest çizgi çiz'; + + @override + String get drawRectangle => 'Dikdörtgen çiz'; + + @override + String get drawRectangleDesc => 'Harita üzerinde dikdörtgen alan çiz'; + + @override + String get measureDistance => 'Mesafe ölç'; + + @override + String get measureDistanceDesc => 'Ölçmek için iki noktaya uzun basın'; + + @override + String get clearMeasurement => 'Ölçümü temizle'; + + @override + String distanceLabel(String distance) { + return 'Mesafe: $distance'; + } + + @override + String get longPressForSecondPoint => 'İkinci nokta için uzun basın'; + + @override + String get longPressToStartMeasurement => + 'İlk noktayı ayarlamak için uzun basın'; + + @override + String get longPressToStartNewMeasurement => + 'Yeni ölçüm başlatmak için uzun basın'; + + @override + String get shareDrawings => 'Çizimleri paylaş'; + + @override + String get clearAllDrawings => 'Tüm çizimleri temizle'; + + @override + String get completeLine => 'Çizgiyi tamamla'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return '$count çizim$plural ekibe yayınla'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Tüm $count çizim$plural kaldır'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Haritadaki tüm $count çizim$plural silinsin mi?'; + } + + @override + String get drawing => 'Çizim'; + + @override + String shareDrawingsCount(int count, String plural) { + return '$count çizim$plural paylaş'; + } + + @override + String get showReceivedDrawings => 'Alınan çizimleri göster'; + + @override + String get showingAllDrawings => 'Tüm çizimler gösteriliyor'; + + @override + String get showingOnlyYourDrawings => + 'Yalnızca sizin çizimleriniz gösteriliyor'; + + @override + String get showSarMarkers => 'SAR işaretlerini göster'; + + @override + String get showingSarMarkers => 'SAR işaretleri gösteriliyor'; + + @override + String get hidingSarMarkers => 'SAR işaretleri gizleniyor'; + + @override + String get clearAll => 'Tümünü temizle'; + + @override + String get publicChannel => 'Genel kanal'; + + @override + String get broadcastToAll => 'Yakındaki tüm düğümlere yayınla (geçici)'; + + @override + String get storedPermanently => 'Odada kalıcı olarak saklanır'; + + @override + String get notConnectedToDevice => 'Cihaza bağlı değil'; + + @override + String get typeYourMessage => 'Mesajınızı yazın...'; + + @override + String get quickLocationMarker => 'Hızlı konum işareti'; + + @override + String get markerType => 'İşaret türü'; + + @override + String get sendTo => 'Gönder'; + + @override + String get noDestinationsAvailable => 'Kullanılabilir hedef yok.'; + + @override + String get selectDestination => 'Hedef seçin...'; + + @override + String get ephemeralBroadcastInfo => + 'Geçici: yalnızca havadan yayınlanır. Saklanmaz; düğümlerin çevrimiçi olması gerekir.'; + + @override + String get persistentRoomInfo => + 'Kalıcı: odada değişmez şekilde saklanır. Otomatik olarak eşitlenir ve çevrimdışı korunur.'; + + @override + String get location => 'Konum'; + + @override + String get fromMap => 'Haritadan'; + + @override + String get gettingLocation => 'Konum alınıyor...'; + + @override + String get locationError => 'Konum hatası'; + + @override + String get retry => 'Tekrar dene'; + + @override + String get refreshLocation => 'Konumu yenile'; + + @override + String accuracyMeters(int accuracy) { + return 'Doğruluk: ±${accuracy}m'; + } + + @override + String get notesOptional => 'Notlar (isteğe bağlı)'; + + @override + String get addAdditionalInformation => 'Ek bilgi ekleyin...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'Konum doğruluğu ±${accuracy}m. Bu, SAR operasyonları için yeterince doğru olmayabilir.\n\nYine de devam edilsin mi?'; + } + + @override + String get loginToRoom => 'Odaya giriş yap'; + + @override + String get enterPasswordInfo => + 'Bu odaya erişmek için parolayı girin. Parola daha sonra kullanılmak üzere kaydedilecektir.'; + + @override + String get password => 'Parola'; + + @override + String get enterRoomPassword => 'Oda parolasını girin'; + + @override + String get loggingInDots => 'Giriş yapılıyor...'; + + @override + String get login => 'Giriş yap'; + + @override + String failedToAddRoom(String error) { + return 'Oda cihaza eklenemedi: $error\n\nOda henüz yayın yapmamış olabilir.\nYayın yapmasını beklemeyi deneyin.'; + } + + @override + String get direct => 'Doğrudan'; + + @override + String get flood => 'Yayılım'; + + @override + String get autoSend => 'Otomatik Gönder'; + + @override + String get autoSendDescription => 'Mevcut yol üzerinden gönder.'; + + @override + String get sendDirect => 'Doğrudan Gönder'; + + @override + String get sendDirectDescription => 'Bu kişiye doğrudan gönder.'; + + @override + String get sendFlood => 'Taşkın Gönder'; + + @override + String get sendFloodDescription => 'Tüm aktarıcılar üzerinden gönder.'; + + @override + String get loggedIn => 'Giriş yapıldı'; + + @override + String get noGpsData => 'GPS verisi yok'; + + @override + String get distance => 'Mesafe'; + + @override + String directPingTimeout(String name) { + return 'Doğrudan ping zaman aşımına uğradı - $name flooding ile yeniden deneniyor...'; + } + + @override + String pingFailed(String name) { + return '$name için ping başarısız - yanıt alınamadı'; + } + + @override + String deleteContactConfirmation(String name) { + return '\"$name\" kişisini silmek istediğinizden emin misiniz?\n\nBu işlem kişiyi hem uygulamadan hem de bağlı radyo cihazından kaldıracaktır.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Kişi kaldırılamadı: $error'; + } + + @override + String get type => 'Tür'; + + @override + String get publicKey => 'Genel anahtar'; + + @override + String get lastSeen => 'Son görülme'; + + @override + String get yes => 'Evet'; + + @override + String get no => 'Hayır'; + + @override + String get permissions => 'İzinler'; + + @override + String get telemetry => 'Telemetri'; + + @override + String get voltage => 'Voltaj'; + + @override + String get battery => 'Pil'; + + @override + String get temperature => 'Sıcaklık'; + + @override + String get humidity => 'Nem'; + + @override + String get pressure => 'Basınç'; + + @override + String get gpsTelemetry => 'GPS (telemetri)'; + + @override + String get reLoginToRoom => 'Odaya yeniden giriş yap'; + + @override + String get heading => 'Yön'; + + @override + String get elevation => 'Rakım'; + + @override + String get accuracy => 'Doğruluk'; + + @override + String get bearing => 'İstikamet'; + + @override + String get direction => 'Yön'; + + @override + String get filterMarkers => 'İşaretleri filtrele'; + + @override + String get filterMarkersTooltip => 'İşaretleri filtrele'; + + @override + String get contactsFilter => 'Kişiler'; + + @override + String get repeatersFilter => 'Tekrarlayıcılar'; + + @override + String get sarMarkers => 'SAR işaretleri'; + + @override + String get foundPerson => 'Bulunan kişi'; + + @override + String get fire => 'Yangın'; + + @override + String get stagingArea => 'Toplanma alanı'; + + @override + String get showAll => 'Tümünü göster'; + + @override + String get locationUnavailable => 'Konum kullanılamıyor'; + + @override + String get ahead => 'ileride'; + + @override + String degreesRight(int degrees) { + return '$degrees° sağda'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° solda'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Enl: $latitude Boy: $longitude'; + } + + @override + String get noContactsYet => 'Henüz kişi yok'; + + @override + String get connectToDeviceToLoadContacts => + 'Kişileri yüklemek için bir cihaza bağlanın'; + + @override + String get teamMembers => 'Ekip üyeleri'; + + @override + String get repeaters => 'Tekrarlayıcılar'; + + @override + String get rooms => 'Odalar'; + + @override + String get channels => 'Kanallar'; + + @override + String get selectMapLayer => 'Harita katmanını seç'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'ESRI Uydu'; + + @override + String get googleHybrid => 'Google Hibrit'; + + @override + String get googleRoadmap => 'Google Yol Haritası'; + + @override + String get googleTerrain => 'Google Arazi'; + + @override + String get dragToPosition => 'Konuma sürükle'; + + @override + String get createSarMarker => 'SAR işareti oluştur'; + + @override + String get compass => 'Pusula'; + + @override + String get navigationAndContacts => 'Navigasyon ve kişiler'; + + @override + String get sarAlert => 'SAR ALARMI'; + + @override + String get textCopiedToClipboard => 'Metin panoya kopyalandı'; + + @override + String get cannotReplySenderMissing => + 'Yanıtlanamıyor: gönderen bilgisi eksik'; + + @override + String get cannotReplyContactNotFound => 'Yanıtlanamıyor: kişi bulunamadı'; + + @override + String get copyText => 'Metni kopyala'; + + @override + String get saveAsTemplate => 'Şablon olarak kaydet'; + + @override + String get templateSaved => 'Şablon başarıyla kaydedildi'; + + @override + String get templateAlreadyExists => 'Bu emoji ile bir şablon zaten var'; + + @override + String get deleteMessage => 'Mesajı sil'; + + @override + String get deleteMessageConfirmation => + 'Bu mesajı silmek istediğinizden emin misiniz?'; + + @override + String get shareLocation => 'Konumu paylaş'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nKoordinatlar: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'SAR konumu'; + + @override + String get justNow => 'Az önce'; + + @override + String minutesAgo(int minutes) { + return '$minutes dk önce'; + } + + @override + String hoursAgo(int hours) { + return '$hours sa önce'; + } + + @override + String daysAgo(int days) { + return '$days g önce'; + } + + @override + String secondsAgo(int seconds) { + return '$seconds sn önce'; + } + + @override + String get sending => 'Gönderiliyor...'; + + @override + String get sent => 'Gönderildi'; + + @override + String get delivered => 'Teslim edildi'; + + @override + String deliveredWithTime(int time) { + return 'Teslim edildi (${time}ms)'; + } + + @override + String get failed => 'Başarısız'; + + @override + String get broadcast => 'Yayın'; + + @override + String deliveredToContacts(int delivered, int total) { + return '$delivered/$total kişiye teslim edildi'; + } + + @override + String get allDelivered => 'Hepsi teslim edildi'; + + @override + String get recipientDetails => 'Alıcı ayrıntıları'; + + @override + String get pending => 'Bekliyor'; + + @override + String get sarMarkerFoundPerson => 'Bulunan kişi'; + + @override + String get sarMarkerFire => 'Yangın konumu'; + + @override + String get sarMarkerStagingArea => 'Toplanma alanı'; + + @override + String get sarMarkerObject => 'Bulunan nesne'; + + @override + String get from => 'Kimden'; + + @override + String get coordinates => 'Koordinatlar'; + + @override + String get tapToViewOnMap => 'Haritada görüntülemek için dokunun'; + + @override + String get radioSettings => 'Radyo ayarları'; + + @override + String get frequencyMHz => 'Frekans (MHz)'; + + @override + String get bandwidth => 'Bant genişliği'; + + @override + String get spreadingFactor => 'Yayılma faktörü'; + + @override + String get codingRate => 'Kodlama oranı'; + + @override + String get txPowerDbm => 'TX gücü (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Maks: $power dBm'; + } + + @override + String get you => 'Siz'; + + @override + String exportFailed(String error) { + return 'Dışa aktarma başarısız: $error'; + } + + @override + String importFailed(String error) { + return 'İçe aktarma başarısız: $error'; + } + + @override + String get unknown => 'Bilinmiyor'; + + @override + String get onlineLayers => 'Çevrimiçi katmanlar'; + + @override + String get locationTrail => 'Konum izi'; + + @override + String get showTrailOnMap => 'İzi haritada göster'; + + @override + String get trailVisible => 'İz haritada görünüyor'; + + @override + String get trailHiddenRecording => 'İz gizli (kayıt devam ediyor)'; + + @override + String get duration => 'Süre'; + + @override + String get points => 'Noktalar'; + + @override + String get clearTrail => 'İzi temizle'; + + @override + String get clearTrailQuestion => 'İz temizlensin mi?'; + + @override + String get clearTrailConfirmation => + 'Geçerli konum izini temizlemek istediğinizden emin misiniz? Bu işlem geri alınamaz.'; + + @override + String get noTrailRecorded => 'Henüz iz kaydedilmedi'; + + @override + String get startTrackingToRecord => + 'İzi kaydetmek için konum takibini başlatın'; + + @override + String get trailControls => 'İz kontrolleri'; + + @override + String get model => 'Model'; + + @override + String get version => 'Sürüm'; + + @override + String get maxContacts => 'Maks kişi'; + + @override + String get maxChannels => 'Maks kanal'; + + @override + String get publicInfo => 'Genel bilgiler'; + + @override + String get useCurrentLocation => 'Geçerli konumu kullan'; + + @override + String get noneUnknown => 'Yok/Bilinmiyor'; + + @override + String get chatNode => 'Sohbet düğümü'; + + @override + String get repeater => 'Tekrarlayıcı'; + + @override + String get roomChannel => 'Oda/Kanal'; + + @override + String typeNumber(int number) { + return 'Tür $number'; + } + + @override + String failedToSave(String error) { + return 'Kaydetme başarısız: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Konum alınamadı: $error'; + } + + @override + String get sarTemplates => 'SAR şablonları'; + + @override + String get addTemplate => 'Şablon ekle'; + + @override + String get editTemplate => 'Şablonu düzenle'; + + @override + String get deleteTemplate => 'Şablonu sil'; + + @override + String get templateName => 'Şablon adı'; + + @override + String get templateNameHint => 'örn. Bulunan kişi'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji gerekli'; + + @override + String get nameRequired => 'Ad gerekli'; + + @override + String get templateDescription => 'Açıklama (isteğe bağlı)'; + + @override + String get templateDescriptionHint => 'Ek bağlam ekleyin...'; + + @override + String get templateColor => 'Renk'; + + @override + String get previewFormat => 'Önizleme (SAR mesaj biçimi)'; + + @override + String get importFromClipboard => 'İçe aktar'; + + @override + String get exportToClipboard => 'Dışa aktar'; + + @override + String deleteTemplateConfirmation(String name) { + return '“$name” şablonu silinsin mi?'; + } + + @override + String get templateAdded => 'Şablon eklendi'; + + @override + String get templateUpdated => 'Şablon güncellendi'; + + @override + String get templateDeleted => 'Şablon silindi'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count şablon içe aktarıldı', + one: '1 şablon içe aktarıldı', + zero: 'Şablon içe aktarılmadı', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count şablon panoya aktarıldı', + one: '1 şablon panoya aktarıldı', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Varsayılanlara sıfırla'; + + @override + String get resetToDefaultsConfirmation => + 'Bu işlem tüm özel şablonları silecek ve 4 varsayılan şablonu geri yükleyecek. Devam edilsin mi?'; + + @override + String get reset => 'Sıfırla'; + + @override + String get resetComplete => 'Şablonlar varsayılana sıfırlandı'; + + @override + String get noTemplates => 'Kullanılabilir şablon yok'; + + @override + String get tapAddToCreate => + 'İlk şablonunuzu oluşturmak için + işaretine dokunun'; + + @override + String get ok => 'Tamam'; + + @override + String get locationPermission => 'Konum izni'; + + @override + String get checking => 'Kontrol ediliyor...'; + + @override + String get locationPermissionGrantedAlways => 'Verildi (Her zaman)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Verildi (Kullanım sırasında)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Reddedildi - istemek için dokunun'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Kalıcı olarak reddedildi - ayarları aç'; + + @override + String get locationPermissionDialogContent => + 'Konum izni kalıcı olarak reddedildi. GPS takibi ve konum paylaşımını kullanmak için lütfen cihaz ayarlarından etkinleştirin.'; + + @override + String get openSettings => 'Ayarları aç'; + + @override + String get locationPermissionGranted => 'Konum izni verildi!'; + + @override + String get locationPermissionRequiredForGps => + 'GPS takibi ve konum paylaşımı için konum izni gereklidir.'; + + @override + String get locationPermissionAlreadyGranted => 'Konum izni zaten verilmiş.'; + + @override + String get sarNavyBlue => 'SAR Lacivert'; + + @override + String get sarNavyBlueDescription => 'Profesyonel/Operasyon modu'; + + @override + String get selectRecipient => 'Alıcı seç'; + + @override + String get broadcastToAllNearby => 'Yakındaki herkese yayınla'; + + @override + String get searchRecipients => 'Alıcı ara...'; + + @override + String get noContactsFound => 'Kişi bulunamadı'; + + @override + String get noRoomsFound => 'Oda bulunamadı'; + + @override + String get noRecipientsAvailable => 'Kullanılabilir alıcı yok'; + + @override + String get noChannelsFound => 'Kanal bulunamadı'; + + @override + String get newMessage => 'Yeni mesaj'; + + @override + String get channel => 'Kanal'; + + @override + String get samplePoliceLead => 'Polis lideri'; + + @override + String get sampleDroneOperator => 'Drone operatörü'; + + @override + String get sampleFirefighterAlpha => 'İtfaiyeci'; + + @override + String get sampleMedicCharlie => 'Sağlık görevlisi'; + + @override + String get sampleCommandDelta => 'Komuta'; + + @override + String get sampleFireEngine => 'İtfaiye aracı'; + + @override + String get sampleAirSupport => 'Hava desteği'; + + @override + String get sampleBaseCoordinator => 'Üs koordinatörü'; + + @override + String get channelEmergency => 'Acil durum'; + + @override + String get channelCoordination => 'Koordinasyon'; + + @override + String get channelUpdates => 'Güncellemeler'; + + @override + String get sampleTeamMember => 'Örnek ekip üyesi'; + + @override + String get sampleScout => 'Örnek keşifçi'; + + @override + String get sampleBase => 'Örnek üs'; + + @override + String get sampleSearcher => 'Örnek arayıcı'; + + @override + String get sampleObjectBackpack => ' Sırt çantası bulundu - mavi renk'; + + @override + String get sampleObjectVehicle => ' Terk edilmiş araç - sahibi kontrol edin'; + + @override + String get sampleObjectCamping => ' Kamp ekipmanı bulundu'; + + @override + String get sampleObjectTrailMarker => ' Yol dışında patika işareti bulundu'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Tüm ekipler durum bildirimi yapsın'; + + @override + String get sampleMsgWeatherUpdate => + 'Hava durumu: açık gökyüzü, sıcaklık 18°C'; + + @override + String get sampleMsgBaseCamp => 'Üs kampı toplanma alanında kuruldu'; + + @override + String get sampleMsgTeamAlpha => 'Ekip sektör 2’ye ilerliyor'; + + @override + String get sampleMsgRadioCheck => + 'Telsiz kontrolü - tüm istasyonlar yanıt versin'; + + @override + String get sampleMsgWaterSupply => + 'Su ikmali 3 numaralı kontrol noktasında mevcut'; + + @override + String get sampleMsgTeamBravo => 'Ekip raporu: sektör 1 temiz'; + + @override + String get sampleMsgEtaRallyPoint => + 'Toplanma noktasına tahmini varış: 15 dakika'; + + @override + String get sampleMsgSupplyDrop => 'İkmal bırakma 14:00 için onaylandı'; + + @override + String get sampleMsgDroneSurvey => 'Drone taraması tamamlandı - bulgu yok'; + + @override + String get sampleMsgTeamCharlie => 'Ekip takviye istiyor'; + + @override + String get sampleMsgRadioDiscipline => + 'Tüm birimler: telsiz disiplinini koruyun'; + + @override + String get sampleMsgUrgentMedical => 'ACİL: sektör 4’te tıbbi yardım gerekli'; + + @override + String get sampleMsgAdultMale => ' Yetişkin erkek, bilinci açık'; + + @override + String get sampleMsgFireSpotted => 'Yangın görüldü - koordinatlar geliyor'; + + @override + String get sampleMsgSpreadingRapidly => ' Hızla yayılıyor!'; + + @override + String get sampleMsgPriorityHelicopter => + 'ÖNCELİK: helikopter desteği gerekli'; + + @override + String get sampleMsgMedicalTeamEnRoute => 'Tıbbi ekip konumunuza geliyor'; + + @override + String get sampleMsgEvacHelicopter => 'Tahliye helikopteri ETA 10 dakika'; + + @override + String get sampleMsgEmergencyResolved => 'Acil durum çözüldü - tehlike yok'; + + @override + String get sampleMsgEmergencyStagingArea => ' Acil durum toplanma alanı'; + + @override + String get sampleMsgEmergencyServices => + 'Acil servisler bilgilendirildi ve müdahale ediyor'; + + @override + String get sampleAlphaTeamLead => 'Ekip lideri'; + + @override + String get sampleBravoScout => 'Keşifçi'; + + @override + String get sampleCharlieMedic => 'Sağlık görevlisi'; + + @override + String get sampleDeltaNavigator => 'Navigatör'; + + @override + String get sampleEchoSupport => 'Destek'; + + @override + String get sampleBaseCommand => 'Üs komutası'; + + @override + String get sampleFieldCoordinator => 'Saha koordinatörü'; + + @override + String get sampleMedicalTeam => 'Tıbbi ekip'; + + @override + String get mapDrawing => 'Harita çizimi'; + + @override + String get navigateToDrawing => 'Çizime git'; + + @override + String get copyCoordinates => 'Koordinatları kopyala'; + + @override + String get hideFromMap => 'Haritadan gizle'; + + @override + String get lineDrawing => 'Çizgi çizimi'; + + @override + String get rectangleDrawing => 'Dikdörtgen çizimi'; + + @override + String get manualCoordinates => 'Manuel koordinatlar'; + + @override + String get enterCoordinatesManually => 'Koordinatları manuel girin'; + + @override + String get latitudeLabel => 'Enlem'; + + @override + String get longitudeLabel => 'Boylam'; + + @override + String get exampleCoordinates => 'Örnek: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Çizimi paylaş'; + + @override + String get shareWithAllNearbyDevices => 'Yakındaki tüm cihazlarla paylaş'; + + @override + String get shareToRoom => 'Odaya paylaş'; + + @override + String get sendToPersistentStorage => 'Kalıcı oda depolamasına gönder'; + + @override + String get deleteDrawingConfirm => + 'Bu çizimi silmek istediğinizden emin misiniz?'; + + @override + String get drawingDeleted => 'Çizim silindi'; + + @override + String yourDrawingsCount(int count) { + return 'Çizimleriniz ($count)'; + } + + @override + String get shared => 'Paylaşıldı'; + + @override + String get line => 'Çizgi'; + + @override + String get rectangle => 'Dikdörtgen'; + + @override + String get updateAvailable => 'Güncelleme mevcut'; + + @override + String get currentVersion => 'Geçerli'; + + @override + String get latestVersion => 'En son'; + + @override + String get downloadUpdate => 'İndir'; + + @override + String get updateLater => 'Daha sonra'; + + @override + String get cadastralParcels => 'Kadastro parselleri'; + + @override + String get forestRoads => 'Orman yolları'; + + @override + String get wmsOverlays => 'WMS katmanları'; + + @override + String get hikingTrails => 'Yürüyüş parkurları'; + + @override + String get mainRoads => 'Ana yollar'; + + @override + String get houseNumbers => 'Kapı numaraları'; + + @override + String get fireHazardZones => 'Yangın tehlike bölgeleri'; + + @override + String get historicalFires => 'Geçmiş yangınlar'; + + @override + String get firebreaks => 'Yangın şeritleri'; + + @override + String get krasFireZones => 'Kras yangın bölgeleri'; + + @override + String get placeNames => 'Yer adları'; + + @override + String get municipalityBorders => 'Belediye sınırları'; + + @override + String get topographicMap => 'Topoğrafik harita 1:25000'; + + @override + String get recentMessages => 'Son mesajlar'; + + @override + String get addChannel => 'Kanal ekle'; + + @override + String get channelName => 'Kanal adı'; + + @override + String get channelNameHint => 'örn. Kurtarma Ekibi Alfa'; + + @override + String get channelSecret => 'Kanal sırrı'; + + @override + String get channelSecretHint => 'Bu kanal için paylaşılan parola'; + + @override + String get channelSecretHelp => + 'Bu sır, bu kanala erişmesi gereken tüm ekip üyeleriyle paylaşılmalıdır'; + + @override + String get channelTypesInfo => + 'Hash kanallar (#team): sır, isimden otomatik üretilir. Aynı isim = tüm cihazlarda aynı kanal.\n\nÖzel kanallar: açık bir sır kullanın. Yalnızca sırrı bilenler katılabilir.'; + + @override + String get hashChannelInfo => + 'Hash kanal: sır kanal adından otomatik üretilecektir. Aynı adı kullanan herkes aynı kanala katılır.'; + + @override + String get channelNameRequired => 'Kanal adı gerekli'; + + @override + String get channelNameTooLong => 'Kanal adı en fazla 31 karakter olabilir'; + + @override + String get channelSecretRequired => 'Kanal sırrı gerekli'; + + @override + String get channelSecretTooLong => + 'Kanal sırrı en fazla 32 karakter olabilir'; + + @override + String get invalidAsciiCharacters => + 'Yalnızca ASCII karakterlere izin verilir'; + + @override + String get channelCreatedSuccessfully => 'Kanal başarıyla oluşturuldu'; + + @override + String channelCreationFailed(String error) { + return 'Kanal oluşturulamadı: $error'; + } + + @override + String get deleteChannel => 'Kanalı sil'; + + @override + String deleteChannelConfirmation(String channelName) { + return '\"$channelName\" kanalını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.'; + } + + @override + String get channelDeletedSuccessfully => 'Kanal başarıyla silindi'; + + @override + String channelDeletionFailed(String error) { + return 'Kanal silinemedi: $error'; + } + + @override + String get createChannel => 'Kanal oluştur'; + + @override + String get wizardBack => 'Geri'; + + @override + String get wizardSkip => 'Atla'; + + @override + String get wizardNext => 'İleri'; + + @override + String get wizardGetStarted => 'Başla'; + + @override + String get wizardWelcomeTitle => 'MeshCore SAR uygulamasına hoş geldiniz'; + + @override + String get viewWelcomeTutorial => 'Karşılama eğitimini görüntüle'; + + @override + String get allTeamContacts => 'Tüm ekip kişileri'; + + @override + String directMessagesInfo(int count) { + return 'ACK’li doğrudan mesajlar. $count ekip üyesine gönderildi.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'SAR işareti $count kişiye gönderildi'; + } + + @override + String get noContactsAvailable => 'Ekip kişisi yok'; + + @override + String get reply => 'Yanıtla'; + + @override + String get technicalDetails => 'Teknik ayrıntılar'; + + @override + String get messageTechnicalDetails => 'Mesaj teknik ayrıntıları'; + + @override + String get linkQuality => 'Bağlantı kalitesi'; + + @override + String get delivery => 'Teslimat'; + + @override + String get status => 'Durum'; + + @override + String get expectedAckTag => 'Beklenen ACK etiketi'; + + @override + String get roundTrip => 'Gidiş dönüş'; + + @override + String get retryAttempt => 'Yeniden deneme sayısı'; + + @override + String get floodFallback => 'Flood yedeği'; + + @override + String get identity => 'Kimlik'; + + @override + String get messageId => 'Mesaj kimliği'; + + @override + String get sender => 'Gönderen'; + + @override + String get senderKey => 'Gönderen anahtarı'; + + @override + String get recipient => 'Alıcı'; + + @override + String get recipientKey => 'Alıcı anahtarı'; + + @override + String get voice => 'Ses'; + + @override + String get voiceId => 'Ses kimliği'; + + @override + String get envelope => 'Zarf'; + + @override + String get sessionProgress => 'Oturum ilerlemesi'; + + @override + String get complete => 'Tamamlandı'; + + @override + String get rawDump => 'Ham döküm'; + + @override + String get cannotRetryMissingRecipient => + 'Yeniden denenemiyor: alıcı bilgisi eksik'; + + @override + String get voiceUnavailable => 'Ses şu anda kullanılamıyor'; + + @override + String get requestingVoice => 'Ses isteniyor'; + + @override + String get device => 'cihaz'; + + @override + String get change => 'Değiştir'; + + @override + String get wizardOverviewDescription => + 'Bu uygulama MeshCore mesajlaşmasını, SAR saha güncellemelerini, haritalamayı ve cihaz araçlarını tek bir yerde birleştirir.'; + + @override + String get wizardOverviewFeature1 => + 'Ana Mesajlar sekmesinden doğrudan mesajlar, oda gönderileri ve kanal mesajları gönderin.'; + + @override + String get wizardOverviewFeature2 => + 'Mesh üzerinden SAR işaretleri, harita çizimleri, ses klipleri ve görseller paylaşın.'; + + @override + String get wizardOverviewFeature3 => + 'BLE veya TCP ile bağlanın, ardından yardımcı radyoyu uygulamanın içinden yönetin.'; + + @override + String get wizardMessagingTitle => 'Mesajlaşma ve saha raporları'; + + @override + String get wizardMessagingDescription => + 'Buradaki mesajlar düz metinden fazlasıdır. Uygulama zaten çeşitli operasyonel yükleri ve aktarım akışlarını destekliyor.'; + + @override + String get wizardMessagingFeature1 => + 'Doğrudan mesajları, oda gönderilerini ve kanal trafiğini tek bir düzenleyiciden gönderin.'; + + @override + String get wizardMessagingFeature2 => + 'Yaygın saha raporları için SAR güncellemeleri ve yeniden kullanılabilir SAR şablonları oluşturun.'; + + @override + String get wizardMessagingFeature3 => + 'Arayüzde ilerleme ve yayın süresi tahminleriyle ses oturumları ve görseller aktarın.'; + + @override + String get wizardConnectDeviceTitle => 'Cihazı bağla'; + + @override + String get wizardConnectDeviceDescription => + 'MeshCore radyonuzu bağlayın, bir ad seçin ve devam etmeden önce bir radyo ön ayarı uygulayın.'; + + @override + String get wizardSetupBadge => 'Kurulum'; + + @override + String get wizardOverviewBadge => 'Genel bakış'; + + @override + String wizardConnectedToDevice(String deviceName) { + return '$deviceName cihazına bağlandı'; + } + + @override + String get wizardNoDeviceConnected => 'Henüz bağlı cihaz yok'; + + @override + String get wizardSkipForNow => 'Şimdilik atla'; + + @override + String get wizardDeviceNameLabel => 'Cihaz adı'; + + @override + String get wizardDeviceNameHelp => + 'Bu ad diğer MeshCore kullanıcılarına duyurulur.'; + + @override + String get wizardConfigRegionLabel => 'Yapılandırma bölgesi'; + + @override + String get wizardConfigRegionHelp => + 'MeshCore\'un resmi ön ayar listesinin tamamını kullanır. Varsayılan EU/UK (Narrow) olur.'; + + @override + String get wizardPresetNote1 => + 'Seçilen ön ayarın yerel radyo düzenlemelerinize uygun olduğundan emin olun.'; + + @override + String get wizardPresetNote2 => + 'Liste, MeshCore yapılandırma aracının resmi ön ayar akışıyla eşleşir.'; + + @override + String get wizardPresetNote3 => + 'Onboarding sırasında varsayılan olarak EU/UK (Narrow) seçili kalır.'; + + @override + String get wizardSaving => 'Kaydediliyor...'; + + @override + String get wizardSaveAndContinue => 'Kaydet ve devam et'; + + @override + String get wizardEnterDeviceName => 'Devam etmeden önce bir cihaz adı girin.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return '$deviceName, $presetName ile kaydedildi.'; + } + + @override + String get wizardNetworkTitle => 'Kişiler, odalar ve tekrarlayıcılar'; + + @override + String get wizardNetworkDescription => + 'Kişiler sekmesi keşfettiğiniz ağı ve zamanla öğrendiğiniz rotaları düzenler.'; + + @override + String get wizardNetworkFeature1 => + 'Ekip üyelerini, tekrarlayıcıları, odaları, kanalları ve bekleyen duyuruları tek listede inceleyin.'; + + @override + String get wizardNetworkFeature2 => + 'Bağlantı karmaşıklaştığında smart ping, oda girişi, öğrenilen yollar ve rota sıfırlama araçlarını kullanın.'; + + @override + String get wizardNetworkFeature3 => + 'Uygulamadan çıkmadan kanallar oluşturun ve ağ hedeflerini yönetin.'; + + @override + String get wizardMapOpsTitle => 'Harita, izler ve paylaşılan geometri'; + + @override + String get wizardMapOpsDescription => + 'Uygulama haritası ayrı bir görüntüleyici olmak yerine mesajlaşma, takip ve SAR katmanlarıyla doğrudan bağlantılıdır.'; + + @override + String get wizardMapOpsFeature1 => + 'Kendi konumunuzu, ekip arkadaşlarının konumlarını ve hareket izlerini harita üzerinde takip edin.'; + + @override + String get wizardMapOpsFeature2 => + 'Mesajlardaki çizimleri açın, satır içinde önizleyin ve gerektiğinde haritadan kaldırın.'; + + @override + String get wizardMapOpsFeature3 => + 'Sahadaki ağ kapsamasını anlamak için tekrarlayıcı harita görünümleri ve paylaşılan katmanları kullanın.'; + + @override + String get wizardToolsTitle => 'Mesajlaşma dışındaki araçlar'; + + @override + String get wizardToolsDescription => + 'Burada dört ana sekmeden fazlası var. Uygulama ayrıca yapılandırma, tanılama ve isteğe bağlı sensör akışları içerir.'; + + @override + String get wizardToolsFeature1 => + 'Radyo ayarlarını, telemetriyi, TX gücünü ve yardımcı cihaz ayrıntılarını değiştirmek için cihaz yapılandırmasını açın.'; + + @override + String get wizardToolsFeature2 => + 'İzlenen sensör panoları ve hızlı yenileme eylemleri istediğinizde Sensörler sekmesini etkinleştirin.'; + + @override + String get wizardToolsFeature3 => + 'Mesh sorunlarını giderirken paket günlükleri, spektrum taraması ve geliştirici tanılamalarını kullanın.'; + + @override + String get postConnectDiscoveryTitle => 'Aktarıcıları şimdi keşfet?'; + + @override + String get 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.'; + + @override + String get contactInSensors => 'Sensörlerde'; + + @override + String get contactAddToSensors => 'Sensörlere ekle'; + + @override + String get contactSetPath => 'Yol ayarla'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName Sensörlere eklendi'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Rota temizlenemedi: $error'; + } + + @override + String get contactRouteCleared => 'Rota temizlendi'; + + @override + String contactRouteSet(String route) { + return 'Rota ayarlandı: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Rota ayarlanamadı: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'ACK zaman aşımı'; + + @override + String get opcode => 'Opcode'; + + @override + String get payload => 'Yük'; + + @override + String get hops => 'Atlama'; + + @override + String get hashSize => 'Hash boyutu'; + + @override + String get pathBytes => 'Yol baytları'; + + @override + String get selectedPath => 'Seçilen yol'; + + @override + String get estimatedTx => 'Tahmini gönderim'; + + @override + String get senderToReceipt => 'Gönderenden alındıya'; + + @override + String get receivedCopies => 'Alınan kopyalar'; + + @override + String get retryCause => 'Yeniden deneme nedeni'; + + @override + String get retryMode => 'Yeniden deneme modu'; + + @override + String get retryResult => 'Yeniden deneme sonucu'; + + @override + String get lastRetry => 'Son deneme'; + + @override + String get rxPackets => 'RX paketleri'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Hız'; + + @override + String get window => 'Pencere'; + + @override + String get posttxDelay => 'Gönderim sonrası gecikme'; + + @override + String get bandpass => 'Bant geçiren'; + + @override + String get bandpassFilterVoice => 'Bant geçiren filtre ses'; + + @override + String get active => 'Aktif'; + + @override + String get addContact => 'Kişi ekle'; + + @override + String get all => 'Tümü'; + + @override + String get clearAllLabel => 'Tümünü temizle'; + + @override + String get clearFilters => 'Filtreleri temizle'; + + @override + String get clearRoute => 'Rotayı temizle'; + + @override + String get clearMessages => 'Mesajları temizle'; + + @override + String get clearScale => 'Ölçeği temizle'; + + @override + String get clearDiscoveries => 'Keşifleri temizle'; + + @override + String get clearOnlineTraceDatabase => 'Çevrimiçi iz veritabanını temizle'; + + @override + String get clearAllChannels => 'Tüm kanalları temizle'; + + @override + String get clearAllContacts => 'Tüm kişileri temizle'; + + @override + String get clearChannels => 'Kanalları temizle'; + + @override + String get clearContacts => 'Kişileri temizle'; + + @override + String get clearPathOnMaxRetry => 'Maks denemede yolu temizle'; + + @override + String get create => 'Oluştur'; + + @override + String get custom => 'Özel'; + + @override + String get defaultValue => 'Varsayılan'; + + @override + String get duplicate => 'Çoğalt'; + + @override + String get editName => 'Adı düzenle'; + + @override + String get open => 'Aç'; + + @override + String get paste => 'Yapıştır'; + + @override + String get preview => 'Önizleme'; + + @override + String get remove => 'Kaldır'; + + @override + String get rename => 'Yeniden adlandır'; + + @override + String get resolveAll => 'Tümünü çöz'; + + @override + String get send => 'Gönder'; + + @override + String get sendAnyway => 'Yine de gönder'; + + @override + String get share => 'Paylaş'; + + @override + String get trace => 'İz'; + + @override + String get discovery => 'Keşif'; + + @override + String get discoverRepeaters => 'Tekrarlayıcıları keşfet'; + + @override + String get discoverSensors => 'Sensörleri keşfet'; + + @override + String get repeaterDiscoverySent => 'Tekrarlayıcı keşfi gönderildi'; + + @override + String get sensorDiscoverySent => 'Sensör keşfi gönderildi'; + + @override + String get clearedPendingDiscoveries => 'Bekleyen keşifler temizlendi.'; + + @override + String get autoDiscovery => 'Otomatik keşif'; + + @override + String get enableAutomaticAdding => 'Otomatik eklemeyi etkinleştir'; + + @override + String get autoaddRepeaters => 'Tekrarlayıcıları otomatik ekle'; + + @override + String get autoaddRoomServers => 'Oda sunucularını otomatik ekle'; + + @override + String get autoaddSensors => 'Sensörleri otomatik ekle'; + + @override + String get autoaddUsers => 'Kullanıcıları otomatik ekle'; + + @override + String get overwriteOldestWhenFull => 'Dolduğunda en eskiyi üzerine yaz'; + + @override + String get storage => 'Depolama'; + + @override + String get dangerZone => 'Tehlikeli bölge'; + + @override + String get profiles => 'Profiller'; + + @override + String get favourites => 'Favoriler'; + + @override + String get sensors => 'Sensörler'; + + @override + String get others => 'Diğerleri'; + + @override + String get gpsModule => 'GPS Modülü'; + + @override + String get liveTraffic => 'Canlı trafik'; + + @override + String get repeatersMap => 'Tekrarlayıcılar haritası'; + + @override + String get blePacketLogs => 'BLE paket günlükleri'; + + @override + String get onlineTraceDatabase => 'Çevrimiçi iz veritabanı'; + + @override + String get routePathByteSize => 'Yol bayt boyutu'; + + @override + String get messageNotifications => 'Mesaj bildirimleri'; + + @override + String get sarAlerts => 'SAR uyarıları'; + + @override + String get discoveryNotifications => 'Keşif bildirimleri'; + + @override + String get updateNotifications => 'Güncelleme bildirimleri'; + + @override + String get muteWhileAppIsOpen => 'Uygulama açıkken sessize al'; + + @override + String get disableContacts => 'Kişileri devre dışı bırak'; + + @override + String get enableSensorsTab => 'Sensörler sekmesini etkinleştir'; + + @override + String get enableProfiles => 'Profilleri etkinleştir'; + + @override + String get nearestRepeaterFallback => 'En yakın tekrarlayıcıya geri dönüş'; + + @override + String get deleteAllStoredMessageHistory => 'Tüm mesaj geçmişini sil'; + + @override + String get messageFontSize => 'Mesaj yazı tipi boyutu'; + + @override + String get rotateMapWithHeading => 'Haritayı yön ile döndür'; + + @override + String get showMapDebugInfo => 'Harita hata ayıklama bilgisini göster'; + + @override + String get openMapInFullscreen => 'Haritayı tam ekranda aç'; + + @override + String get showSarMarkersLabel => 'SAR işaretçilerini göster'; + + @override + String get hideRepeatersOnMap => 'Haritada tekrarlayıcıları gizle'; + + @override + String get setMapScale => 'Harita ölçeğini ayarla'; + + @override + String get customMapScaleSaved => 'Özel harita ölçeği kaydedildi'; + + @override + String get voiceBitrate => 'Ses bit hızı'; + + @override + String get voiceCompressor => 'Ses sıkıştırıcı'; + + @override + String get voiceLimiter => 'Ses sınırlayıcı'; + + @override + String get micAutoGain => 'Mikrofon otomatik kazancı'; + + @override + String get echoCancellation => 'Yankı iptali'; + + @override + String get noiseSuppression => 'Gürültü bastırma'; + + @override + String get trimSilenceInVoiceMessages => 'Sesli mesajlarda sessizliği kırp'; + + @override + String get compressor => 'Sıkıştırıcı'; + + @override + String get limiter => 'Sınırlayıcı'; + + @override + String get autoGain => 'Otomatik kazanç'; + + @override + String get echoCancel => 'Yankı'; + + @override + String get noiseSuppress => 'Gürültü'; + + @override + String get silenceTrim => 'Sessizlik'; + + @override + String get maxImageSize => 'Maksimum resim boyutu'; + + @override + String get imageCompression => 'Resim sıkıştırma'; + + @override + String get grayscale => 'Gri tonlama'; + + @override + String get ultraMode => 'Ultra mod'; + + @override + String get fastPrivateGpsUpdates => 'Hızlı özel GPS güncellemeleri'; + + @override + String get movementThreshold => 'Hareket eşiği'; + + @override + String get fastGpsMovementThreshold => 'Hızlı GPS hareket eşiği'; + + @override + String get fastGpsActiveuseInterval => 'Hızlı GPS aktif kullanım aralığı'; + + @override + String get activeuseUpdateInterval => 'Aktif kullanım güncelleme aralığı'; + + @override + String get repeatNearbyTraffic => 'Yakın trafiği tekrarla'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Ağ üzerindeki tekrarlayıcılar aracılığıyla ilet'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Yalnızca yakın, tekrarlayıcı taşkını olmadan'; + + @override + String get multihop => 'Çok atlamalı'; + + @override + String get createProfile => 'Profil oluştur'; + + @override + String get renameProfile => 'Profili yeniden adlandır'; + + @override + String get newProfile => 'Yeni profil'; + + @override + String get manageProfiles => 'Profilleri yönet'; + + @override + String get enableProfilesToStartManagingThem => + 'Profilleri yönetmeye başlamak için etkinleştirin.'; + + @override + String get openMessage => 'Mesajı aç'; + + @override + String get jumpToTheRelatedSarMessage => 'İlgili SAR mesajına git'; + + @override + String get removeSarMarker => 'SAR işaretçisini kaldır'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'SAR işaretçisi göndermek için bir hedef seçin'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'SAR işaretçisi genel kanala yayınlandı'; + + @override + String get sarMarkerSentToRoom => 'SAR işaretçisi odaya gönderildi'; + + @override + String get loadFromGallery => 'Galeriden yükle'; + + @override + String get replaceImage => 'Resmi değiştir'; + + @override + String get selectFromGallery => 'Galeriden seç'; + + @override + String get team => 'Takım'; + + @override + String get found => 'Bulundu'; + + @override + String get staging => 'Toplanma alanı'; + + @override + String get object => 'Nesne'; + + @override + String get quiet => 'Sessiz'; + + @override + String get busy => 'Meşgul'; + + @override + String get searchMessages => 'Mesajlarda ara'; + + @override + String get sendImageFromGallery => 'Galeriden resim gönder'; + + @override + String get takePhoto => 'Fotoğraf çek'; + + @override + String get allMessages => 'Tüm mesajlar'; + + @override + String get sendToPublicChannel => 'Genel kanala gönderilsin mi?'; + + @override + String get selectMarkerTypeAndDestination => + 'İşaretçi türünü ve hedefi seçin'; + + @override + String get noDestinationsAvailableLabel => 'Kullanılabilir hedef yok'; + + @override + String get image => 'Resim'; + + @override + String get format => 'Biçim'; + + @override + String get dimensions => 'Boyutlar'; + + @override + String get segments => 'Segmentler'; + + @override + String get transfers => 'Transferler'; + + @override + String get downloadedBy => 'İndiren'; + + @override + String get saveDiscoverySettings => 'Keşif ayarlarını kaydet'; + + @override + String get savePublicInfo => 'Genel bilgileri kaydet'; + + @override + String get saveRadioSettings => 'Radyo ayarlarını kaydet'; + + @override + String get savePath => 'Yolu kaydet'; + + @override + String get wipeDeviceData => 'Cihaz verilerini sil'; + + @override + String get wipeDevice => 'Cihazı sil'; + + @override + String get destructiveDeviceActions => 'Yıkıcı cihaz işlemleri.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Bir ön ayar seçin veya radyo ayarlarını ince ayarlayın.'; + + @override + String get used => 'Kullanılan'; + + @override + String get total => 'Toplam'; + + @override + String get renameValue => 'Değeri yeniden adlandır'; + + @override + String get customizeFields => 'Alanları özelleştir'; + + @override + String get livePreview => 'Canlı önizleme'; + + @override + String get refreshSchedule => 'Yenileme programı'; + + @override + String get noResponse => 'Yanıt yok'; + + @override + String get refreshing => 'Yenileniyor'; + + @override + String get unavailable => 'Kullanılamaz'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'İzlemek için bir röle veya düğüm seçin.'; + + @override + String get publicKeyLabel => 'Genel anahtar'; + + @override + String get alreadyInContacts => 'Zaten kişilerde'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Kişi eklemeden önce bir cihaza bağlanın'; + + @override + String get fromContacts => 'Kişilerden'; + + @override + String get onlineOnly => 'Yalnızca çevrimiçi'; + + @override + String get inBoth => 'Her ikisinde'; + + @override + String get source => 'Kaynak'; + + @override + String get allMessagesCleared => 'Tüm mesajlar temizlendi'; + + @override + String get onlineTraceDatabaseCleared => 'Çevrimiçi iz veritabanı temizlendi'; + + @override + String get packetLogsCleared => 'Paket günlükleri temizlendi'; + + @override + String get hexDataCopiedToClipboard => 'Hex verisi panoya kopyalandı'; + + @override + String get developerModeEnabled => 'Geliştirici modu etkinleştirildi'; + + @override + String get developerModeDisabled => 'Geliştirici modu devre dışı bırakıldı'; + + @override + String get clipboardIsEmpty => 'Pano boş'; + + @override + String get contactImported => 'Kişi içe aktarıldı'; + + @override + String get contactLinkCopiedToClipboard => + 'Kişi bağlantısı panoya kopyalandı'; + + @override + String get failedToExportContact => 'Kişi dışa aktarılamadı'; + + @override + String get noLogsToExport => 'Dışa aktarılacak günlük yok'; + + @override + String get exportAsCsv => 'CSV olarak dışa aktar'; + + @override + String get exportAsText => 'Metin olarak dışa aktar'; + + @override + String get receivedRfc3339 => 'Alındı (RFC3339)'; + + @override + String get buildTime => 'Derleme zamanı'; + + @override + String get downloadUrlNotAvailable => 'İndirme URL\'si mevcut değil'; + + @override + String get cannotOpenDownloadUrl => 'İndirme URL\'si açılamıyor'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'Güncelleme kontrolü yalnızca Android\'de kullanılabilir'; + + @override + String get youAreRunningTheLatestVersion => 'En son sürümü kullanıyorsunuz'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Güncelleme mevcut ancak indirme URL\'si bulunamadı'; + + @override + String get startTictactoe => 'Tic-Tac-Toe başlat'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe kullanılamıyor'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: rakip bilinmiyor'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: başlangıç bekleniyor'; + + @override + String get acceptsShareLinks => 'Paylaşım bağlantılarını kabul eder'; + + @override + String get supportsRawHex => 'Ham hex destekler'; + + @override + String get clipboardfriendly => 'Pano dostu'; + + @override + String get captured => 'Yakalandı'; + + @override + String get size => 'Boyut'; + + @override + String get noCustomChannelsToClear => 'Temizlenecek özel kanal yok.'; + + @override + String get noDeviceContactsToClear => 'Temizlenecek cihaz kişisi yok.'; + + @override + String get aToZ => 'A-Z'; + + @override + String get add => 'Ekle'; + + @override + String get addAdditionalDetails => 'Ek ayrıntılar ekle'; + + @override + String get addPeer => 'Eş ekle'; + + @override + String get addPeerManually => 'Eşi elle ekle'; + + @override + String addedToContacts(String name) { + return '$name kişilere eklendi'; + } + + @override + String addedToSensors(String name) { + return '$name Sensörlere eklendi'; + } + + @override + String get allowAll => 'Tümüne izin ver'; + + @override + String get anonymousRxStats => 'Anonim RX istatistikleri'; + + @override + String get appearance => 'Görünüm'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Temel telemetri'; + + @override + String get blePin => 'BLE PIN'; + + @override + String get buzzerAlerts => 'Buzzer uyarıları'; + + @override + String get cancelImageReceive => 'Görüntü alımını iptal et'; + + @override + String get choose => 'Seç'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Gösterilecek sekmeleri ve kişi bölümlerini seçin'; + + @override + String get clearCache => 'Önbelleği temizle'; + + @override + String get clearHistory => 'Geçmişi temizle'; + + @override + String get clearLiveView => 'Canlı görünümü temizle'; + + @override + String get clearLogs => 'Günlükleri temizle'; + + @override + String get clearOfflineCache => 'Çevrimdışı önbellek temizlensin mi?'; + + @override + String get clearOverlay => 'Katmanı temizle'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Rotayı yalnızca tüm yeniden denemeler başarısız olduktan sonra temizle'; + + @override + String get codecBitrateAndAudioProcessing => 'Codec, bit hızı ve ses işleme'; + + @override + String get connectByIpAddress => 'IP adresiyle bağlan'; + + @override + String get controlWhichAlertsYouReceive => + 'Hangi uyarıları alacağınızı kontrol edin'; + + @override + String get copyFullHex => 'Tam Hex kopyala'; + + @override + String get copyRawResponse => 'Ham yanıtı kopyala'; + + @override + String get couldNotLoadMetHistory => 'MET geçmişi yüklenemedi'; + + @override + String customizeSensor(String name) { + return '$name sensörünü özelleştir'; + } + + @override + String get data => 'Veri'; + + @override + String get deleteGroup => 'Grubu sil'; + + @override + String deleteStyleConfirm(String name) { + return '$name silinsin mi?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Bu $type silinsin mi?'; + } + + @override + String get deny => 'Reddet'; + + @override + String get deviceInfo => 'Cihaz bilgisi'; + + @override + String get deviceSettings => 'Cihaz Ayarları'; + + @override + String get deviceTimeSynced => 'Cihaz saati bu telefonla senkronize edildi.'; + + @override + String get disableFastGpsPublishing => 'Hızlı GPS yayınını devre dışı bırak'; + + @override + String get displayMarkersAndTraceDatabase => + 'Ekran, işaretçiler ve iz veritabanı'; + + @override + String get download => 'İndir'; + + @override + String get environmentalTelemetry => 'Çevresel telemetri'; + + @override + String errorCheckingForUpdates(String error) { + return 'Güncelleme kontrolünde hata: $error'; + } + + @override + String errorGeneric(String error) { + return 'Hata: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'İndirme açılırken hata: $error'; + } + + @override + String get exportLogs => 'Günlükleri dışa aktar'; + + @override + String get extraAggressiveAvifCompression => 'Ekstra agresif AVIF sıkıştırma'; + + @override + String failedToAddContact(String error) { + return 'Kişi eklenemedi: $error'; + } + + @override + String failedToAddName(String name, String error) { + return '$name eklenemedi: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Kanallar temizlenemedi: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Kişiler temizlenemedi: $error'; + } + + @override + String get failedToConnectViaSerial => 'Seri bağlantı kurulamadı'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Önizleme görüntüsü yüklenemedi: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'İz yüklenemedi: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'SAR işaretçisi gönderilemedi: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Buzzer modu ayarlanamadı: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'GPS modu ayarlanamadı: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Cihaz saati senkronize edilemedi: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Cihaz verileri silinemedi: $error'; + } + + @override + String get fastGpsTargetChannel => 'Hızlı GPS hedef kanalı'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Hızlı güncellemeler, eşikler ve izinler'; + + @override + String get fetchingCatalog => 'Katalog getiriliyor...'; + + @override + String get fifteenMinutes => '15 dakika'; + + @override + String get fifteenSeconds => '15 saniye'; + + @override + String get filterByDirection => 'Yöne göre filtrele'; + + @override + String get finish => 'Bitir'; + + @override + String get fiveMinutes => '5 dakika'; + + @override + String get fiveSeconds => '5 saniye'; + + @override + String get fix => 'Düzelt'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'GPS ilan politikası'; + + @override + String get gpsAndLocation => 'GPS ve Konum'; + + @override + String get gpsDiagnostics => 'GPS tanılama'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Yardım'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Gizli'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Radyonun keşfedilen düğümleri otomatik olarak nasıl eklediği.'; + + @override + String get images => 'Görüntüler'; + + @override + String get importProfile => 'Profili içe aktar'; + + @override + String get lastFix => 'Son konum'; + + @override + String get latest => 'En yeni'; + + @override + String get latitude => 'Enlem'; + + @override + String get loadASavedRegion => 'Kayıtlı bir bölge yükle'; + + @override + String get loading => 'Yükleniyor'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Konum paylaşımı, donanım ve güncelleme aralığı.'; + + @override + String get locationTelemetry => 'Konum telemetrisi'; + + @override + String get lockDestination => 'Hedefi kilitle'; + + @override + String get longitude => 'Boylam'; + + @override + String get mapStyle => 'Harita Stili'; + + @override + String get max => 'Maks'; + + @override + String get maxZoom => 'Maks Yakınlaştırma'; + + @override + String get messaging => 'Mesajlaşma'; + + @override + String get metHistory => 'MET geçmişi'; + + @override + String get min => 'Min'; + + @override + String get minZoom => 'Min Yakınlaştırma'; + + @override + String get moveDown => 'Aşağı taşı'; + + @override + String get moveUp => 'Yukarı taşı'; + + @override + String get multiAckMode => 'Çoklu ACK modu'; + + @override + String get multiDeviceWorkspaceManagement => + 'Çoklu cihaz çalışma alanı yönetimi'; + + @override + String get myLocation => 'Konumum'; + + @override + String get nameAndTelemetryShared => + 'Ad ve telemetri diğer cihazlarla paylaşılır.'; + + @override + String newVersionAvailable(String version) { + return 'Yeni sürüm: $version'; + } + + @override + String get newer => 'Daha yeni'; + + @override + String get noNeighboursFound => 'Komşu bulunamadı'; + + @override + String get noServersFound => 'Sunucu bulunamadı'; + + @override + String get notSet => 'Ayarlanmadı'; + + @override + String get notifications => 'Bildirimler'; + + @override + String get offZeroSeconds => 'Kapalı (0 sn)'; + + @override + String get offlineMaps => 'Çevrimdışı Haritalar'; + + @override + String get older => 'Daha eski'; + + @override + String get oneByteMode0 => '1 bayt (mod 0)'; + + @override + String get oneHour => '1 saat'; + + @override + String get oneMinute => '1 dakika'; + + @override + String get openPacketLogs => 'Paket günlüklerini aç'; + + @override + String get packetTypeHelp => 'Paket türü yardımı'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Paylaşım bağlantısı veya Hex ilanı yapıştırın'; + + @override + String get pathHash => 'Yol hash'; + + @override + String get pathSize => 'Yol boyutu'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Çokgen'; + + @override + String get rawResponseCopied => 'Ham yanıt kopyalandı'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Başarısızlık durumunda en yakın aktarıcı üzerinden yeniden gönder'; + + @override + String get resolveContact => 'Kişiyi çözümle'; + + @override + String get routingRetriesAndDestinationLock => + 'Yönlendirme, yeniden denemeler ve hedef kilidi'; + + @override + String get samples => 'Örnekler'; + + @override + String get satellites => 'Uydular'; + + @override + String get saving => 'Kaydediliyor...'; + + @override + String get search => 'Ara'; + + @override + String get searchByNameOrToken => 'Ada veya jetona göre ara'; + + @override + String get searchDiscoveredNodes => 'Keşfedilen düğümleri ara'; + + @override + String get searchInCurrentFilter => 'Geçerli filtrede ara'; + + @override + String get searchLogs => 'Günlüklerde ara...'; + + @override + String get searchSensors => 'Sensörleri ara'; + + @override + String get searchThisSection => 'Bu bölümde ara'; + + @override + String get selectedRelays => 'Seçili aktarıcılar'; + + @override + String get sensor => 'Sensör'; + + @override + String serialError(String error) { + return 'Seri hata: $error'; + } + + @override + String setPathForContact(String name) { + return '$name için yol ayarla'; + } + + @override + String get shareLiveGps => 'Canlı GPS paylaş'; + + @override + String get shareMyTiles => 'Döşemelerimi paylaş'; + + @override + String get sharingLocationFromPhone => + 'Bu kanalda telefondan konum paylaşılıyor.'; + + @override + String get sharingLocationFromRadio => + 'Bu kanalda radyodan konum paylaşılıyor.'; + + @override + String signalDbm(String rssi) { + return 'Sinyal $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => 'Boyut, sıkıştırma ve önizleme'; + + @override + String get simpleMode => 'Basit mod'; + + @override + String get simpleModeDescription => + 'Yalnızca favori kişilerle sohbet ve harita'; + + @override + String get simpleModeRequiresLockedDestination => + 'Basit modu kullanmak için önce bir kanalla \"Hedefi kilitle\" seçeneğini etkinleştirin'; + + @override + String get noFavouriteContacts => 'Favori kişi yok'; + + @override + String get skip => 'Atla'; + + @override + String get smallerFileSize => 'Daha küçük dosya boyutu'; + + @override + String get sort => 'Sırala'; + + @override + String get stoppedSharingLocation => 'Bu kanalda konum paylaşımı durduruldu.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Ön plandayken bildirimleri bastır'; + + @override + String get tabsAndNavigation => 'Sekmeler ve Gezinme'; + + @override + String get templatesAndTutorials => 'Şablonlar ve öğreticiler'; + + @override + String get tenMinutes => '10 dakika'; + + @override + String get tenSeconds => '10 saniye'; + + @override + String get testSendUpdate => 'Test güncelleme gönder'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Tema, dil ve görüntüleme tercihleri'; + + @override + String get thirtyMinutes => '30 dakika'; + + @override + String get thirtySeconds => '30 saniye'; + + @override + String get threeBytesMode2 => '3 bayt (mod 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Trafik istatistikleri, mesaj geçmişi ve geliştirici araçları'; + + @override + String get twoBytesMode1 => '2 bayt (mod 1)'; + + @override + String get twoMinutes => '2 dakika'; + + @override + String get undo => 'Geri al'; + + @override + String get updated => 'Güncellendi'; + + @override + String get useContactFlags => 'Kişi bayraklarını kullan'; + + @override + String get useSavedCoordinates => 'Kayıtlı koordinatları kullan'; + + @override + String get view => 'Görüntüle'; + + @override + String get viewNeighbours => 'Komşuları Görüntüle'; + + @override + String get viewPublicStats => 'Herkese açık istatistikleri görüntüle'; + + @override + String get setRegionScope => 'Bölge kapsamını ayarla'; + + @override + String get regionScope => 'Bölge kapsamı'; + + @override + String get regionScopeNone => 'Yok (genel)'; + + @override + String get clearRegionScope => 'Kapsamı temizle'; + + @override + String get regionScopeWarning => + 'Yalnızca bu bölgeye izin veren aktarıcılar iletecektir.'; + + @override + String get discoverRegions => 'Aktarıcılardan keşfet'; + + @override + String get discoveringRegions => 'Bölgeler keşfediliyor...'; + + @override + String get enterRegionName => 'Bölge adı (ör. auckland)'; + + @override + String get noRegionsFound => 'Bu aktarıcıda bölge bulunamadı.'; + + @override + String regionScopeSet(String name) { + return 'Bölge kapsamı $name olarak ayarlandı'; + } + + @override + String get regionScopeCleared => 'Bölge kapsamı temizlendi'; + + @override + String get selectDestinationFirst => 'Önce bir kanal, kişi veya oda seçin'; + + @override + String get recipientNoLongerAvailable => 'Alıcı artık mevcut değil'; + + @override + String failedToSendMessage(String error) { + return 'Gönderilemedi: $error'; + } + + @override + String get tictactoeDirectOnly => + 'XOX yalnızca doğrudan mesajlarda çalışır. Önce bir kişi seçin.'; + + @override + String get deviceKeyUnavailable => 'Cihaz anahtarı mevcut değil'; + + @override + String get imageCompressionFailed => 'Resim sıkıştırma başarısız'; + + @override + String get imageFragmentationFailed => 'Resim parçalama başarısız'; + + @override + String get failedToAnnounceImage => 'Resim duyurulamadı'; + + @override + String get noRecipientSelected => 'Alıcı seçilmedi'; + + @override + String get imageSendFailed => 'Resim gönderilemedi'; + + @override + String get microphonePermissionRequiredForVoice => + 'Sesli mesajlar için mikrofon izni gerekli'; + + @override + String get recordingDiscarded => 'Kayıt silindi'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Genel kanala $mediaType göndermek üzeresiniz. Mesh ağındaki herkes bunu alabileceği için bu önerilmez. Bunu özellikle istemiyorsanız özel veya etiketli bir kanal seçin.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Konumunuzu paylaşmak için özel bir kanal seçin.'; + + @override + String get stopSharingMyLocation => 'Konumumu paylaşmayı durdur'; + + @override + String get shareMyLocation => 'Konumumu paylaş'; + + @override + String get stopRecording => 'Kaydı durdur'; + + @override + String get recordVoice => 'Sesli mesaj kaydet'; + + @override + String get moreActions => 'Diğer işlemler'; + + @override + String get noMessagesInFilter => 'Bu filtrede mesaj yok'; + + @override + String get noMatchesInFilter => 'Bu filtrede eşleşme yok'; + + @override + String syncFailed(String error) { + return 'Senkronizasyon başarısız: $error'; + } + + @override + String get invalidLink => 'Geçersiz bağlantı'; + + @override + String get cannotOpenLink => 'Bağlantı açılamıyor'; + + @override + String get failedToOpenLink => 'Bağlantı açılamadı'; + + @override + String get failedToResendMessage => 'Mesaj yeniden gönderilemedi'; + + @override + String retryFailed(String error) { + return 'Yeniden deneme başarısız: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Çizimi ve mesajı sil'; + + @override + String get lastEchoRelay => 'Son yankı: röle'; + + @override + String get lastEchoPath => 'Son yankı: yol'; + + @override + String get lastEchoBytesReport => 'Son yankı: bayt raporu'; + + @override + String get noGpsCoordinatesAvailable => 'Kullanılabilir GPS koordinatı yok'; + + @override + String get notASarMarker => 'Bu bir SAR işareti değil'; + + @override + String failedToSaveTemplate(String error) { + return 'Şablon kaydedilemedi: $error'; + } + + @override + String get drawingNotFound => 'Çizim bulunamadı'; + + @override + String get unknownDrawingType => 'Bilinmeyen çizim türü'; + + @override + String get customMapMarker => 'Özel harita işareti'; + + @override + String customMapPointLabel(String point) { + return 'Nokta: $point'; + } + + @override + String mapIdLabel(String id) { + return 'Harita ID: $id'; + } + + @override + String get cannotFetchVoice => 'Ses alınamıyor'; + + @override + String get senderContactUnknown => + 'Gönderenin kişisi bilinmiyor. Önce kişileri senkronize edin.'; + + @override + String get senderRouteUnknown => + 'Gönderenin rotası bilinmiyor. Önce kişileri/yolu senkronize edin.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Mesaj çok uzakta ($hops atlama, en fazla $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'Gönderenin rotası yol denetimine yanıt vermedi. Kişileri/yolu senkronize edip yeniden deneyin.'; + + @override + String get senderRouteNoRawResponse => + 'Gönderenin rotası ham aktarım yolunda yanıt vermedi.'; + + @override + String voiceFetchOverHops(String hops) { + return '$hops atlama üzerinden ses alma biraz sürebilir.'; + } + + @override + String get voiceReceiveCanceled => 'Ses alımı iptal edildi'; + + @override + String get fetchingMissingVoiceFragments => 'Eksik ses parçaları alınıyor'; + + @override + String get receivingVoice => 'Ses alınıyor'; + + @override + String get cannotFetchImage => 'Resim alınamıyor'; + + @override + String imageFetchOverHops(String hops) { + return '$hops atlama üzerinden resim alma biraz sürebilir.'; + } + + @override + String get imageAlreadyBeingReceived => 'Resim zaten alınıyor'; + + @override + String get loadImage => 'Resmi yükle'; + + @override + String get imageFetchFailedToSendRequest => 'Resim isteği gönderilemedi'; + + @override + String get imageUnavailable => 'Resim şu anda kullanılamıyor'; + + @override + String get imageFetchTimedOut => 'Resim alma zaman aşımına uğradı'; + + @override + String get imageReceiveCanceled => 'Resim alımı iptal edildi'; + + @override + String get closeImagePreview => 'Resim önizlemesini kapat'; + + @override + String get recordingReleaseToSend => 'Kaydediliyor... göndermek için bırakın'; + + @override + String get sendingVoice => 'Ses gönderiliyor...'; + + @override + String get sendLongPressToRecordVoice => + 'Gönder (ses kaydı için basılı tutun)'; + + @override + String get discardRecording => 'Kaydı sil'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count atlama', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Yakındaki mesh trafiği için yayın kanalları'; + + @override + String get roomsSectionDescription => + 'Sürekli ekip koordinasyonu için ortak alanlar'; + + @override + String get contactsSectionDescription => + 'Doğrudan ulaşabileceğiniz kişiler ve cihazlar'; + + @override + String get noRecentChatters => 'Yakın zamanda konuşan yok'; + + @override + String get public => 'Genel'; + + @override + String get failedToSendTicTacToeMove => 'XOX hamlesi gönderilemedi'; + + @override + String get youWon => 'Kazandınız'; + + @override + String get opponentWon => 'Rakip kazandı'; + + @override + String get gameDraw => 'Berabere'; + + @override + String get yourTurn => 'Sıra sizde'; + + @override + String get opponentTurn => 'Sıra rakipte'; + + @override + String get pleaseEnterValidCoordinates => 'Geçerli koordinatlar girin'; + + @override + String get locationNotAvailable => 'Konum mevcut değil'; + + @override + String get customCaveMapPoint => 'Özel mağara haritası noktası'; + + @override + String get mapPoint => 'Harita noktası'; + + @override + String get fetchingMissingImageFragments => 'Eksik parçalar alınıyor…'; + + @override + String get loadingImage => 'Yükleniyor…'; + + @override + String get receivingImage => 'Alınıyor…'; + + @override + String get tapToLoad => 'Yüklemek için dokunun'; + + @override + String get connectDevice => 'Cihaza bağlan'; + + @override + String get chooseTransportSubtitle => + 'Bluetooth, WiFi veya Seri aktarım seçin'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Ağ'; + + @override + String get serial => 'Seri'; + + @override + String get scan => 'Tara'; + + @override + String get pressScanToSearchForDevices => + 'Yakındaki cihazları aramak için tara düğmesine basın'; + + @override + String failedToConnectToDevice(String name) { + return '$name cihazına bağlanılamadı'; + } + + @override + String get unknownDevice => 'Bilinmeyen cihaz'; + + @override + String get showingCachedResultsTapRefresh => + 'Önbelleğe alınmış sonuçlar gösteriliyor. Yeniden taramak için yenile düğmesine dokunun.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Yerel ağda $port bağlantı noktasındaki MeshCore WiFi cihazları taranıyor'; + } + + @override + String get cancelScanAndAddServer => 'Taramayı iptal et ve sunucu ekle'; + + @override + String get addServer => 'Sunucu ekle'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Taranıyor... $scanned/$total IP'; + } + + @override + String get cancelAndEnterManually => 'İptal et ve elle gir'; + + @override + String get noRecentOrDiscoveredServers => + 'Henüz son kullanılan veya keşfedilen sunucu yok'; + + @override + String get recentlyUsed => 'Son kullanılanlar'; + + @override + String get connecting => 'Bağlanıyor...'; + + @override + String get discoveredOnThisNetwork => 'Bu ağda keşfedildi'; + + @override + String serverNoLongerAvailable(String host, int port) { + return '$host:$port adresindeki sunucu artık kullanılamıyor. Etkin sunucuları bulmak için lütfen yeniden tarayın.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return '$host:$port adresine bağlanılamadı'; + } + + @override + String get enterValidIpAddress => 'Geçerli bir IP adresi girin'; + + @override + String get enterValidTcpPort => 'Geçerli bir TCP bağlantı noktası girin'; + + @override + String get ipAddress => 'IP adresi'; + + @override + String get tcpPort => 'TCP bağlantı noktası'; + + @override + String get customServerPort => 'Özel sunucu bağlantı noktası'; + + @override + String get chat => 'Sohbet'; + + @override + String get restoringPreviousLink => 'Önceki bağlantı geri yükleniyor'; + + @override + String get noDeviceConnected => 'Bağlı cihaz yok'; + + @override + String get sendAdvert => 'Kişi bilgimi gönder'; + + @override + String get advertMode => 'Kişi bilgimi gönder'; + + @override + String get advertModeSubtitle => + 'Kişi bilgilerinizin ne kadar uzağa gideceğini seçin.'; + + @override + String get floodAdvertSent => 'Kişi bilgisi tüm mesh ağına gönderildi'; + + @override + String get directAdvertSent => 'Kişi bilgisi yakındaki cihazlara gönderildi'; +} diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart new file mode 100644 index 0000000..4a76538 --- /dev/null +++ b/lib/l10n/app_localizations_uk.dart @@ -0,0 +1,3456 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Ukrainian (`uk`). +class AppLocalizationsUk extends AppLocalizations { + AppLocalizationsUk([String locale = 'uk']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => 'Повідомлення'; + + @override + String get contacts => 'Контакти'; + + @override + String get map => 'Мапа'; + + @override + String get settings => 'Налаштування'; + + @override + String get connect => 'Підключити'; + + @override + String get disconnect => 'Відключити'; + + @override + String get noDevicesFound => 'Пристроїв не знайдено'; + + @override + String get scanAgain => 'Сканувати знову'; + + @override + String get deviceNotConnected => 'Пристрій не підключено'; + + @override + String get locationPermissionDenied => 'Доступ до геолокації відхилено'; + + @override + String get locationPermissionPermanentlyDenied => + 'Дозвіл на геолокацію назавжди відхилено. Увімкніть його в Налаштуваннях.'; + + @override + String get locationPermissionRequired => + 'Для GPS-відстеження та координації команди потрібен дозвіл на геолокацію. Ви можете ввімкнути його пізніше в Налаштуваннях.'; + + @override + String get locationServicesDisabled => + 'Служби геолокації вимкнені. Увімкніть їх у Налаштуваннях.'; + + @override + String get failedToGetGpsLocation => 'Не вдалося отримати GPS-координати'; + + @override + String failedToAdvertise(String error) { + return 'Не вдалося транслювати: $error'; + } + + @override + String get cancelReconnection => 'Скасувати повторне підключення'; + + @override + String get general => 'Загальні'; + + @override + String get theme => 'Тема'; + + @override + String get chooseTheme => 'Вибрати тему'; + + @override + String get light => 'Світла'; + + @override + String get dark => 'Темна'; + + @override + String get blueLightTheme => 'Світло-синя тема'; + + @override + String get blueDarkTheme => 'Темно-синя тема'; + + @override + String get sarRed => 'SAR Червоний'; + + @override + String get alertEmergencyMode => 'Режим тривоги/надзвичайної ситуації'; + + @override + String get sarGreen => 'SAR Зелений'; + + @override + String get safeAllClearMode => 'Безпечний режим/усе чисто'; + + @override + String get autoSystem => 'Авто (система)'; + + @override + String get followSystemTheme => 'Слідувати системній темі'; + + @override + String get showRxTxIndicators => 'Показувати індикатори RX/TX'; + + @override + String get disableMap => 'Вимкнути мапу'; + + @override + String get language => 'Мова'; + + @override + String get chooseLanguage => 'Вибрати мову'; + + @override + String get save => 'Зберегти'; + + @override + String get cancel => 'Скасувати'; + + @override + String get close => 'Закрити'; + + @override + String get about => 'Про програму'; + + @override + String get appVersion => 'Версія застосунку'; + + @override + String get aboutMeshCoreSar => 'Про MeshCore SAR'; + + @override + String get aboutDescription => + 'Застосунок для пошуку та рятування, створений для команд екстреного реагування. Можливості:\n\n• BLE mesh-мережа для зв’язку між пристроями\n• Офлайн-мапи з кількома шарами\n• Відстеження членів команди в реальному часі\n• Тактичні маркери SAR (знайдена людина, пожежа, зона збору)\n• Керування контактами та повідомленнями\n• GPS-відстеження з компасом\n• Кешування тайлів мапи для офлайн-використання'; + + @override + String get technologiesUsed => 'Використані технології:'; + + @override + String get technologiesList => + '• Flutter для кросплатформної розробки\n• BLE (Bluetooth Low Energy) для mesh-мережі\n• OpenStreetMap для мап\n• Provider для керування станом\n• SharedPreferences для локального зберігання'; + + @override + String get moreInfo => 'Більше інформації'; + + @override + String get packageName => 'Назва пакета'; + + @override + String get sampleData => 'Тестові дані'; + + @override + String get sampleDataDescription => + 'Завантажити або очистити тестові контакти, повідомлення каналів і маркери SAR для перевірки'; + + @override + String get loadSampleData => 'Завантажити тестові дані'; + + @override + String get clearAllData => 'Очистити всі дані'; + + @override + String get clearAllDataConfirmTitle => 'Очистити всі дані'; + + @override + String get clearAllDataConfirmMessage => + 'Це очистить усі контакти та маркери SAR. Ви впевнені?'; + + @override + String get clear => 'Очистити'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return 'Завантажено $teamCount членів команди, $channelCount каналів, $sarCount маркерів SAR, $messageCount повідомлень'; + } + + @override + String failedToLoadSampleData(String error) { + return 'Не вдалося завантажити тестові дані: $error'; + } + + @override + String get allDataCleared => 'Усі дані очищено'; + + @override + String get failedToStartBackgroundTracking => + 'Не вдалося запустити фонове відстеження. Перевірте дозволи та BLE-з’єднання.'; + + @override + String locationBroadcast(String latitude, String longitude) { + return 'Трансляція місцезнаходження: $latitude, $longitude'; + } + + @override + String get defaultPinInfo => + 'Стандартний PIN для пристроїв без екрана - 123456. Проблеми зі сполученням? Видаліть Bluetooth-пристрій у системних налаштуваннях.'; + + @override + String get noMessagesYet => 'Повідомлень ще немає'; + + @override + String get pullDownToSync => + 'Потягніть вниз, щоб синхронізувати повідомлення'; + + @override + String get deleteContact => 'Видалити контакт'; + + @override + String get delete => 'Видалити'; + + @override + String get viewOnMap => 'Показати на мапі'; + + @override + String get refresh => 'Оновити'; + + @override + String get resetPath => 'Скинути маршрут (побудувати заново)'; + + @override + String copiedToClipboard(String label) { + return '$label скопійовано в буфер обміну'; + } + + @override + String get pleaseEnterPassword => 'Будь ласка, введіть пароль'; + + @override + String failedToSyncContacts(String error) { + return 'Не вдалося синхронізувати контакти: $error'; + } + + @override + String get loggedInSuccessfully => + 'Вхід виконано успішно! Очікування повідомлень кімнати...'; + + @override + String get loginFailed => 'Помилка входу - неправильний пароль'; + + @override + String loggingIn(String roomName) { + return 'Вхід до $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return 'Не вдалося надіслати вхід: $error'; + } + + @override + String get lowLocationAccuracy => 'Низька точність геолокації'; + + @override + String get continue_ => 'Продовжити'; + + @override + String get sendSarMarker => 'Надіслати маркер SAR'; + + @override + String get deleteDrawing => 'Видалити рисунок'; + + @override + String get drawingTools => 'Інструменти малювання'; + + @override + String get drawLine => 'Намалювати лінію'; + + @override + String get drawLineDesc => 'Намалювати довільну лінію на мапі'; + + @override + String get drawRectangle => 'Намалювати прямокутник'; + + @override + String get drawRectangleDesc => 'Намалювати прямокутну область на мапі'; + + @override + String get measureDistance => 'Виміряти відстань'; + + @override + String get measureDistanceDesc => 'Затисніть дві точки, щоб виміряти'; + + @override + String get clearMeasurement => 'Очистити вимірювання'; + + @override + String distanceLabel(String distance) { + return 'Відстань: $distance'; + } + + @override + String get longPressForSecondPoint => 'Затисніть для другої точки'; + + @override + String get longPressToStartMeasurement => + 'Затисніть, щоб встановити першу точку'; + + @override + String get longPressToStartNewMeasurement => + 'Затисніть, щоб почати нове вимірювання'; + + @override + String get shareDrawings => 'Поділитися рисунками'; + + @override + String get clearAllDrawings => 'Очистити всі рисунки'; + + @override + String get completeLine => 'Завершити лінію'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return 'Транслювати $count рисунок$plural команді'; + } + + @override + String removeAllDrawings(int count, String plural) { + return 'Видалити всі $count рисунок$plural'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return 'Видалити всі $count рисунок$plural з мапи?'; + } + + @override + String get drawing => 'Рисунок'; + + @override + String shareDrawingsCount(int count, String plural) { + return 'Поділитися $count рисунок$plural'; + } + + @override + String get showReceivedDrawings => 'Показати отримані рисунки'; + + @override + String get showingAllDrawings => 'Показуються всі рисунки'; + + @override + String get showingOnlyYourDrawings => 'Показуються лише ваші рисунки'; + + @override + String get showSarMarkers => 'Показати маркери SAR'; + + @override + String get showingSarMarkers => 'Показуються маркери SAR'; + + @override + String get hidingSarMarkers => 'Приховування маркерів SAR'; + + @override + String get clearAll => 'Очистити все'; + + @override + String get publicChannel => 'Публічний канал'; + + @override + String get broadcastToAll => 'Транслювати всім сусіднім вузлам (тимчасово)'; + + @override + String get storedPermanently => 'Постійно збережено в кімнаті'; + + @override + String get notConnectedToDevice => 'Не підключено до пристрою'; + + @override + String get typeYourMessage => 'Введіть повідомлення...'; + + @override + String get quickLocationMarker => 'Швидкий маркер місця'; + + @override + String get markerType => 'Тип маркера'; + + @override + String get sendTo => 'Надіслати до'; + + @override + String get noDestinationsAvailable => 'Немає доступних отримувачів.'; + + @override + String get selectDestination => 'Виберіть отримувача...'; + + @override + String get ephemeralBroadcastInfo => + 'Тимчасове: транслюється лише по радіо. Не зберігається - вузли мають бути онлайн.'; + + @override + String get persistentRoomInfo => + 'Постійне: незмінно зберігається в кімнаті. Автоматично синхронізується і зберігається офлайн.'; + + @override + String get location => 'Місцезнаходження'; + + @override + String get fromMap => 'З мапи'; + + @override + String get gettingLocation => 'Отримання місцезнаходження...'; + + @override + String get locationError => 'Помилка геолокації'; + + @override + String get retry => 'Повторити'; + + @override + String get refreshLocation => 'Оновити місцезнаходження'; + + @override + String accuracyMeters(int accuracy) { + return 'Точність: ±$accuracyм'; + } + + @override + String get notesOptional => 'Нотатки (необов’язково)'; + + @override + String get addAdditionalInformation => 'Додайте додаткову інформацію...'; + + @override + String lowAccuracyWarning(int accuracy) { + return 'Точність місцезнаходження становить ±$accuracyм. Це може бути недостатньо точно для операцій SAR.\n\nПродовжити все одно?'; + } + + @override + String get loginToRoom => 'Увійти до кімнати'; + + @override + String get enterPasswordInfo => + 'Введіть пароль для доступу до цієї кімнати. Пароль буде збережено для подальшого використання.'; + + @override + String get password => 'Пароль'; + + @override + String get enterRoomPassword => 'Введіть пароль кімнати'; + + @override + String get loggingInDots => 'Вхід...'; + + @override + String get login => 'Увійти'; + + @override + String failedToAddRoom(String error) { + return 'Не вдалося додати кімнату до пристрою: $error\n\nМожливо, кімната ще не почала трансляцію.\nСпробуйте зачекати, поки вона почне транслюватися.'; + } + + @override + String get direct => 'Напряму'; + + @override + String get flood => 'Широкомовно'; + + @override + String get autoSend => 'Автовідправлення'; + + @override + String get autoSendDescription => 'Надіслати поточним маршрутом.'; + + @override + String get sendDirect => 'Пряме надсилання'; + + @override + String get sendDirectDescription => 'Надіслати безпосередньо цьому контакту.'; + + @override + String get sendFlood => 'Надсилання лавиною'; + + @override + String get sendFloodDescription => 'Надіслати через усі ретранслятори.'; + + @override + String get loggedIn => 'Увійшли'; + + @override + String get noGpsData => 'Немає GPS-даних'; + + @override + String get distance => 'Відстань'; + + @override + String directPingTimeout(String name) { + return 'Час очікування прямого ping вичерпано - повторна спроба для $name через flooding...'; + } + + @override + String pingFailed(String name) { + return 'Ping до $name не вдався - відповіді не отримано'; + } + + @override + String deleteContactConfirmation(String name) { + return 'Ви впевнені, що хочете видалити \"$name\"?\n\nЦе видалить контакт і з застосунку, і з пов’язаного радіопристрою.'; + } + + @override + String failedToRemoveContact(String error) { + return 'Не вдалося видалити контакт: $error'; + } + + @override + String get type => 'Тип'; + + @override + String get publicKey => 'Публічний ключ'; + + @override + String get lastSeen => 'Востаннє бачили'; + + @override + String get yes => 'Так'; + + @override + String get no => 'Ні'; + + @override + String get permissions => 'Дозволи'; + + @override + String get telemetry => 'Телеметрія'; + + @override + String get voltage => 'Напруга'; + + @override + String get battery => 'Батарея'; + + @override + String get temperature => 'Температура'; + + @override + String get humidity => 'Вологість'; + + @override + String get pressure => 'Тиск'; + + @override + String get gpsTelemetry => 'GPS (телеметрія)'; + + @override + String get reLoginToRoom => 'Повторно увійти до кімнати'; + + @override + String get heading => 'Напрямок'; + + @override + String get elevation => 'Висота'; + + @override + String get accuracy => 'Точність'; + + @override + String get bearing => 'Пеленг'; + + @override + String get direction => 'Напрямок'; + + @override + String get filterMarkers => 'Фільтрувати маркери'; + + @override + String get filterMarkersTooltip => 'Фільтрувати маркери'; + + @override + String get contactsFilter => 'Контакти'; + + @override + String get repeatersFilter => 'Ретранслятори'; + + @override + String get sarMarkers => 'Маркери SAR'; + + @override + String get foundPerson => 'Знайдена людина'; + + @override + String get fire => 'Пожежа'; + + @override + String get stagingArea => 'Зона збору'; + + @override + String get showAll => 'Показати все'; + + @override + String get locationUnavailable => 'Місцезнаходження недоступне'; + + @override + String get ahead => 'попереду'; + + @override + String degreesRight(int degrees) { + return '$degrees° праворуч'; + } + + @override + String degreesLeft(int degrees) { + return '$degrees° ліворуч'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return 'Шир.: $latitude Довг.: $longitude'; + } + + @override + String get noContactsYet => 'Контактів ще немає'; + + @override + String get connectToDeviceToLoadContacts => + 'Підключіться до пристрою, щоб завантажити контакти'; + + @override + String get teamMembers => 'Члени команди'; + + @override + String get repeaters => 'Ретранслятори'; + + @override + String get rooms => 'Кімнати'; + + @override + String get channels => 'Канали'; + + @override + String get selectMapLayer => 'Вибрати шар мапи'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'ESRI Супутник'; + + @override + String get googleHybrid => 'Google Гібрид'; + + @override + String get googleRoadmap => 'Google Дорожня карта'; + + @override + String get googleTerrain => 'Google Рельєф'; + + @override + String get dragToPosition => 'Перетягніть у позицію'; + + @override + String get createSarMarker => 'Створити маркер SAR'; + + @override + String get compass => 'Компас'; + + @override + String get navigationAndContacts => 'Навігація та контакти'; + + @override + String get sarAlert => 'ТРИВОГА SAR'; + + @override + String get textCopiedToClipboard => 'Текст скопійовано в буфер обміну'; + + @override + String get cannotReplySenderMissing => + 'Неможливо відповісти: відсутня інформація про відправника'; + + @override + String get cannotReplyContactNotFound => + 'Неможливо відповісти: контакт не знайдено'; + + @override + String get copyText => 'Копіювати текст'; + + @override + String get saveAsTemplate => 'Зберегти як шаблон'; + + @override + String get templateSaved => 'Шаблон успішно збережено'; + + @override + String get templateAlreadyExists => 'Шаблон із цим emoji уже існує'; + + @override + String get deleteMessage => 'Видалити повідомлення'; + + @override + String get deleteMessageConfirmation => + 'Ви впевнені, що хочете видалити це повідомлення?'; + + @override + String get shareLocation => 'Поділитися місцезнаходженням'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\nКоординати: $lat, $lon\n\nGoogle Maps: $url'; + } + + @override + String get sarLocationShare => 'Місцезнаходження SAR'; + + @override + String get justNow => 'Щойно'; + + @override + String minutesAgo(int minutes) { + return '$minutes хв тому'; + } + + @override + String hoursAgo(int hours) { + return '$hours год тому'; + } + + @override + String daysAgo(int days) { + return '$days дн тому'; + } + + @override + String secondsAgo(int seconds) { + return '$seconds с тому'; + } + + @override + String get sending => 'Надсилання...'; + + @override + String get sent => 'Надіслано'; + + @override + String get delivered => 'Доставлено'; + + @override + String deliveredWithTime(int time) { + return 'Доставлено (${time}ms)'; + } + + @override + String get failed => 'Помилка'; + + @override + String get broadcast => 'Трансляція'; + + @override + String deliveredToContacts(int delivered, int total) { + return 'Доставлено $delivered/$total контактам'; + } + + @override + String get allDelivered => 'Усе доставлено'; + + @override + String get recipientDetails => 'Деталі отримувача'; + + @override + String get pending => 'Очікує'; + + @override + String get sarMarkerFoundPerson => 'Знайдена людина'; + + @override + String get sarMarkerFire => 'Місце пожежі'; + + @override + String get sarMarkerStagingArea => 'Зона збору'; + + @override + String get sarMarkerObject => 'Знайдений об’єкт'; + + @override + String get from => 'Від'; + + @override + String get coordinates => 'Координати'; + + @override + String get tapToViewOnMap => 'Торкніться, щоб переглянути на мапі'; + + @override + String get radioSettings => 'Налаштування радіо'; + + @override + String get frequencyMHz => 'Частота (MHz)'; + + @override + String get bandwidth => 'Ширина смуги'; + + @override + String get spreadingFactor => 'Коефіцієнт розширення'; + + @override + String get codingRate => 'Швидкість кодування'; + + @override + String get txPowerDbm => 'Потужність TX (dBm)'; + + @override + String maxPowerDbm(int power) { + return 'Макс: $power dBm'; + } + + @override + String get you => 'Ви'; + + @override + String exportFailed(String error) { + return 'Помилка експорту: $error'; + } + + @override + String importFailed(String error) { + return 'Помилка імпорту: $error'; + } + + @override + String get unknown => 'Невідомо'; + + @override + String get onlineLayers => 'Онлайн-шари'; + + @override + String get locationTrail => 'Слід місцезнаходження'; + + @override + String get showTrailOnMap => 'Показати слід на мапі'; + + @override + String get trailVisible => 'Слід видно на мапі'; + + @override + String get trailHiddenRecording => 'Слід прихований (запис триває)'; + + @override + String get duration => 'Тривалість'; + + @override + String get points => 'Точки'; + + @override + String get clearTrail => 'Очистити слід'; + + @override + String get clearTrailQuestion => 'Очистити слід?'; + + @override + String get clearTrailConfirmation => + 'Ви впевнені, що хочете очистити поточний слід місцезнаходження? Цю дію не можна скасувати.'; + + @override + String get noTrailRecorded => 'Слід ще не записано'; + + @override + String get startTrackingToRecord => + 'Почніть відстеження місцезнаходження, щоб записати слід'; + + @override + String get trailControls => 'Керування слідом'; + + @override + String get model => 'Модель'; + + @override + String get version => 'Версія'; + + @override + String get maxContacts => 'Макс. контактів'; + + @override + String get maxChannels => 'Макс. каналів'; + + @override + String get publicInfo => 'Публічна інформація'; + + @override + String get useCurrentLocation => 'Використати поточне місцезнаходження'; + + @override + String get noneUnknown => 'Немає/Невідомо'; + + @override + String get chatNode => 'Вузол чату'; + + @override + String get repeater => 'Ретранслятор'; + + @override + String get roomChannel => 'Кімната/Канал'; + + @override + String typeNumber(int number) { + return 'Тип $number'; + } + + @override + String failedToSave(String error) { + return 'Не вдалося зберегти: $error'; + } + + @override + String failedToGetLocation(String error) { + return 'Не вдалося отримати місцезнаходження: $error'; + } + + @override + String get sarTemplates => 'Шаблони SAR'; + + @override + String get addTemplate => 'Додати шаблон'; + + @override + String get editTemplate => 'Редагувати шаблон'; + + @override + String get deleteTemplate => 'Видалити шаблон'; + + @override + String get templateName => 'Назва шаблону'; + + @override + String get templateNameHint => 'напр. Знайдена людина'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji обов’язковий'; + + @override + String get nameRequired => 'Назва обов’язкова'; + + @override + String get templateDescription => 'Опис (необов’язково)'; + + @override + String get templateDescriptionHint => 'Додайте додатковий контекст...'; + + @override + String get templateColor => 'Колір'; + + @override + String get previewFormat => 'Попередній перегляд (формат повідомлення SAR)'; + + @override + String get importFromClipboard => 'Імпорт'; + + @override + String get exportToClipboard => 'Експорт'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Видалити шаблон \"$name\"?'; + } + + @override + String get templateAdded => 'Шаблон додано'; + + @override + String get templateUpdated => 'Шаблон оновлено'; + + @override + String get templateDeleted => 'Шаблон видалено'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Імпортовано $count шаблонів', + one: 'Імпортовано 1 шаблон', + zero: 'Шаблони не імпортовано', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Експортовано $count шаблонів у буфер обміну', + one: 'Експортовано 1 шаблон у буфер обміну', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Скинути до стандартних'; + + @override + String get resetToDefaultsConfirmation => + 'Це видалить усі користувацькі шаблони та відновить 4 стандартні шаблони. Продовжити?'; + + @override + String get reset => 'Скинути'; + + @override + String get resetComplete => 'Шаблони скинуто до стандартних'; + + @override + String get noTemplates => 'Немає доступних шаблонів'; + + @override + String get tapAddToCreate => 'Торкніться +, щоб створити перший шаблон'; + + @override + String get ok => 'OK'; + + @override + String get locationPermission => 'Дозвіл на геолокацію'; + + @override + String get checking => 'Перевірка...'; + + @override + String get locationPermissionGrantedAlways => 'Надано (Завжди)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Надано (Під час використання)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Відхилено - торкніться, щоб запросити'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Назавжди відхилено - відкрити налаштування'; + + @override + String get locationPermissionDialogContent => + 'Дозвіл на геолокацію назавжди відхилено. Увімкніть його в налаштуваннях пристрою, щоб використовувати GPS та поширення місцезнаходження.'; + + @override + String get openSettings => 'Відкрити налаштування'; + + @override + String get locationPermissionGranted => 'Дозвіл на геолокацію надано!'; + + @override + String get locationPermissionRequiredForGps => + 'Для GPS-відстеження та поширення місцезнаходження потрібен дозвіл на геолокацію.'; + + @override + String get locationPermissionAlreadyGranted => + 'Дозвіл на геолокацію вже надано.'; + + @override + String get sarNavyBlue => 'SAR Темно-синій'; + + @override + String get sarNavyBlueDescription => 'Професійний/операційний режим'; + + @override + String get selectRecipient => 'Вибрати отримувача'; + + @override + String get broadcastToAllNearby => 'Транслювати всім поблизу'; + + @override + String get searchRecipients => 'Пошук отримувачів...'; + + @override + String get noContactsFound => 'Контактів не знайдено'; + + @override + String get noRoomsFound => 'Кімнат не знайдено'; + + @override + String get noRecipientsAvailable => 'Немає доступних отримувачів'; + + @override + String get noChannelsFound => 'Каналів не знайдено'; + + @override + String get newMessage => 'Нове повідомлення'; + + @override + String get channel => 'Канал'; + + @override + String get samplePoliceLead => 'Керівник поліції'; + + @override + String get sampleDroneOperator => 'Оператор дрона'; + + @override + String get sampleFirefighterAlpha => 'Пожежник'; + + @override + String get sampleMedicCharlie => 'Медик'; + + @override + String get sampleCommandDelta => 'Командування'; + + @override + String get sampleFireEngine => 'Пожежна машина'; + + @override + String get sampleAirSupport => 'Повітряна підтримка'; + + @override + String get sampleBaseCoordinator => 'Координатор бази'; + + @override + String get channelEmergency => 'Надзвичайна ситуація'; + + @override + String get channelCoordination => 'Координація'; + + @override + String get channelUpdates => 'Оновлення'; + + @override + String get sampleTeamMember => 'Тестовий член команди'; + + @override + String get sampleScout => 'Тестовий розвідник'; + + @override + String get sampleBase => 'Тестова база'; + + @override + String get sampleSearcher => 'Тестовий пошуковець'; + + @override + String get sampleObjectBackpack => ' Знайдено рюкзак - синього кольору'; + + @override + String get sampleObjectVehicle => + ' Покинутий транспорт - перевірити власника'; + + @override + String get sampleObjectCamping => ' Знайдено туристичне спорядження'; + + @override + String get sampleObjectTrailMarker => + ' Знайдено маркер стежки поза маршрутом'; + + @override + String get sampleMsgAllTeamsCheckIn => 'Усі команди - дайте відмітку'; + + @override + String get sampleMsgWeatherUpdate => + 'Оновлення погоди: ясно, температура 18°C'; + + @override + String get sampleMsgBaseCamp => 'Базовий табір розгорнуто в зоні збору'; + + @override + String get sampleMsgTeamAlpha => 'Команда рухається до сектора 2'; + + @override + String get sampleMsgRadioCheck => + 'Перевірка радіо - усім станціям відповісти'; + + @override + String get sampleMsgWaterSupply => + 'Запас води доступний на контрольній точці 3'; + + @override + String get sampleMsgTeamBravo => 'Команда доповідає: сектор 1 чистий'; + + @override + String get sampleMsgEtaRallyPoint => + 'Орієнтовний час до точки збору: 15 хвилин'; + + @override + String get sampleMsgSupplyDrop => 'Скидання постачання підтверджено на 14:00'; + + @override + String get sampleMsgDroneSurvey => + 'Обстеження дроном завершено - результатів немає'; + + @override + String get sampleMsgTeamCharlie => 'Команда просить підкріплення'; + + @override + String get sampleMsgRadioDiscipline => + 'Усім підрозділам: дотримуйтеся радіодисципліни'; + + @override + String get sampleMsgUrgentMedical => + 'ТЕРМІНОВО: потрібна медична допомога в секторі 4'; + + @override + String get sampleMsgAdultMale => ' Дорослий чоловік, при свідомості'; + + @override + String get sampleMsgFireSpotted => 'Помічено пожежу - координати надходять'; + + @override + String get sampleMsgSpreadingRapidly => ' Швидко поширюється!'; + + @override + String get sampleMsgPriorityHelicopter => + 'ПРІОРИТЕТ: потрібна підтримка гелікоптера'; + + @override + String get sampleMsgMedicalTeamEnRoute => + 'Медична команда прямує до вашого місця'; + + @override + String get sampleMsgEvacHelicopter => + 'ETA евакуаційного гелікоптера 10 хвилин'; + + @override + String get sampleMsgEmergencyResolved => + 'Надзвичайну ситуацію вирішено - все чисто'; + + @override + String get sampleMsgEmergencyStagingArea => ' Аварійна зона збору'; + + @override + String get sampleMsgEmergencyServices => + 'Екстрені служби повідомлені та вже реагують'; + + @override + String get sampleAlphaTeamLead => 'Керівник команди'; + + @override + String get sampleBravoScout => 'Розвідник'; + + @override + String get sampleCharlieMedic => 'Медик'; + + @override + String get sampleDeltaNavigator => 'Навігатор'; + + @override + String get sampleEchoSupport => 'Підтримка'; + + @override + String get sampleBaseCommand => 'Командування бази'; + + @override + String get sampleFieldCoordinator => 'Польовий координатор'; + + @override + String get sampleMedicalTeam => 'Медична команда'; + + @override + String get mapDrawing => 'Рисунок на мапі'; + + @override + String get navigateToDrawing => 'Перейти до рисунка'; + + @override + String get copyCoordinates => 'Копіювати координати'; + + @override + String get hideFromMap => 'Приховати з мапи'; + + @override + String get lineDrawing => 'Лінійний рисунок'; + + @override + String get rectangleDrawing => 'Рисунок прямокутника'; + + @override + String get manualCoordinates => 'Ручні координати'; + + @override + String get enterCoordinatesManually => 'Введіть координати вручну'; + + @override + String get latitudeLabel => 'Широта'; + + @override + String get longitudeLabel => 'Довгота'; + + @override + String get exampleCoordinates => 'Приклад: 46.0569, 14.5058'; + + @override + String get shareDrawing => 'Поділитися рисунком'; + + @override + String get shareWithAllNearbyDevices => + 'Поділитися з усіма пристроями поблизу'; + + @override + String get shareToRoom => 'Поділитися в кімнату'; + + @override + String get sendToPersistentStorage => 'Надіслати в постійне сховище кімнати'; + + @override + String get deleteDrawingConfirm => + 'Ви впевнені, що хочете видалити цей рисунок?'; + + @override + String get drawingDeleted => 'Рисунок видалено'; + + @override + String yourDrawingsCount(int count) { + return 'Ваші рисунки ($count)'; + } + + @override + String get shared => 'Спільний'; + + @override + String get line => 'Лінія'; + + @override + String get rectangle => 'Прямокутник'; + + @override + String get updateAvailable => 'Доступне оновлення'; + + @override + String get currentVersion => 'Поточна'; + + @override + String get latestVersion => 'Остання'; + + @override + String get downloadUpdate => 'Завантажити'; + + @override + String get updateLater => 'Пізніше'; + + @override + String get cadastralParcels => 'Кадастрові ділянки'; + + @override + String get forestRoads => 'Лісові дороги'; + + @override + String get wmsOverlays => 'Накладки WMS'; + + @override + String get hikingTrails => 'Пішохідні стежки'; + + @override + String get mainRoads => 'Основні дороги'; + + @override + String get houseNumbers => 'Номери будинків'; + + @override + String get fireHazardZones => 'Зони пожежної небезпеки'; + + @override + String get historicalFires => 'Історичні пожежі'; + + @override + String get firebreaks => 'Протипожежні смуги'; + + @override + String get krasFireZones => 'Пожежні зони Красу'; + + @override + String get placeNames => 'Назви місць'; + + @override + String get municipalityBorders => 'Межі громад'; + + @override + String get topographicMap => 'Топографічна мапа 1:25000'; + + @override + String get recentMessages => 'Останні повідомлення'; + + @override + String get addChannel => 'Додати канал'; + + @override + String get channelName => 'Назва каналу'; + + @override + String get channelNameHint => 'напр. Рятувальна команда Альфа'; + + @override + String get channelSecret => 'Секрет каналу'; + + @override + String get channelSecretHint => 'Спільний пароль для цього каналу'; + + @override + String get channelSecretHelp => + 'Цей секрет має бути спільним для всіх членів команди, яким потрібен доступ до цього каналу'; + + @override + String get channelTypesInfo => + 'Hash-канали (#team): секрет автоматично генерується з назви. Однакова назва = той самий канал на всіх пристроях.\n\nПриватні канали: використовуйте явний секрет. Приєднатися можуть лише ті, хто його знає.'; + + @override + String get hashChannelInfo => + 'Hash-канал: секрет буде автоматично згенерований з назви каналу. Кожен, хто використовує ту саму назву, приєднається до того самого каналу.'; + + @override + String get channelNameRequired => 'Назва каналу обов’язкова'; + + @override + String get channelNameTooLong => + 'Назва каналу має бути не довшою за 31 символ'; + + @override + String get channelSecretRequired => 'Секрет каналу обов’язковий'; + + @override + String get channelSecretTooLong => + 'Секрет каналу має бути не довшим за 32 символи'; + + @override + String get invalidAsciiCharacters => 'Дозволені лише символи ASCII'; + + @override + String get channelCreatedSuccessfully => 'Канал успішно створено'; + + @override + String channelCreationFailed(String error) { + return 'Не вдалося створити канал: $error'; + } + + @override + String get deleteChannel => 'Видалити канал'; + + @override + String deleteChannelConfirmation(String channelName) { + return 'Ви впевнені, що хочете видалити канал \"$channelName\"? Цю дію не можна скасувати.'; + } + + @override + String get channelDeletedSuccessfully => 'Канал успішно видалено'; + + @override + String channelDeletionFailed(String error) { + return 'Не вдалося видалити канал: $error'; + } + + @override + String get createChannel => 'Створити канал'; + + @override + String get wizardBack => 'Назад'; + + @override + String get wizardSkip => 'Пропустити'; + + @override + String get wizardNext => 'Далі'; + + @override + String get wizardGetStarted => 'Почати'; + + @override + String get wizardWelcomeTitle => 'Ласкаво просимо до MeshCore SAR'; + + @override + String get viewWelcomeTutorial => 'Переглянути вступний посібник'; + + @override + String get allTeamContacts => 'Усі контакти команди'; + + @override + String directMessagesInfo(int count) { + return 'Прямі повідомлення з ACK. Надіслано $count членам команди.'; + } + + @override + String sarMarkerSentToContacts(int count) { + return 'Маркер SAR надіслано $count контактам'; + } + + @override + String get noContactsAvailable => 'Немає доступних контактів команди'; + + @override + String get reply => 'Відповісти'; + + @override + String get technicalDetails => 'Технічні деталі'; + + @override + String get messageTechnicalDetails => 'Технічні деталі повідомлення'; + + @override + String get linkQuality => 'Якість зв’язку'; + + @override + String get delivery => 'Доставка'; + + @override + String get status => 'Статус'; + + @override + String get expectedAckTag => 'Очікуваний тег ACK'; + + @override + String get roundTrip => 'Час в обидва боки'; + + @override + String get retryAttempt => 'Спроба повтору'; + + @override + String get floodFallback => 'Резервний flooding'; + + @override + String get identity => 'Ідентичність'; + + @override + String get messageId => 'ID повідомлення'; + + @override + String get sender => 'Відправник'; + + @override + String get senderKey => 'Ключ відправника'; + + @override + String get recipient => 'Отримувач'; + + @override + String get recipientKey => 'Ключ отримувача'; + + @override + String get voice => 'Голос'; + + @override + String get voiceId => 'ID голосу'; + + @override + String get envelope => 'Конверт'; + + @override + String get sessionProgress => 'Прогрес сеансу'; + + @override + String get complete => 'Завершено'; + + @override + String get rawDump => 'Сирий дамп'; + + @override + String get cannotRetryMissingRecipient => + 'Неможливо повторити: відсутня інформація про отримувача'; + + @override + String get voiceUnavailable => 'Голос зараз недоступний'; + + @override + String get requestingVoice => 'Запит голосу'; + + @override + String get device => 'пристрій'; + + @override + String get change => 'Змінити'; + + @override + String get wizardOverviewDescription => + 'Ця програма поєднує повідомлення MeshCore, польові оновлення SAR, карти та інструменти пристрою в одному місці.'; + + @override + String get wizardOverviewFeature1 => + 'Надсилайте особисті повідомлення, повідомлення кімнат і повідомлення каналів з основної вкладки «Повідомлення».'; + + @override + String get wizardOverviewFeature2 => + 'Діліться SAR-маркерами, малюнками на карті, голосовими кліпами та зображеннями через mesh-мережу.'; + + @override + String get wizardOverviewFeature3 => + 'Підключайтеся через BLE або TCP, а потім керуйте супутнім радіопристроєм прямо з програми.'; + + @override + String get wizardMessagingTitle => 'Повідомлення та польові звіти'; + + @override + String get wizardMessagingDescription => + 'Повідомлення тут це більше, ніж просто текст. Програма вже підтримує кілька типів операційних даних і сценаріїв передачі.'; + + @override + String get wizardMessagingFeature1 => + 'Надсилайте особисті повідомлення, повідомлення кімнат і трафік каналів з одного редактора.'; + + @override + String get wizardMessagingFeature2 => + 'Створюйте оновлення SAR і багаторазові шаблони SAR для типових польових звітів.'; + + @override + String get wizardMessagingFeature3 => + 'Передавайте голосові сесії та зображення з індикатором прогресу й оцінками ефірного часу в інтерфейсі.'; + + @override + String get wizardConnectDeviceTitle => 'Підключити пристрій'; + + @override + String get wizardConnectDeviceDescription => + 'Підключіть своє радіо MeshCore, виберіть назву та застосуйте радіопрофіль перед продовженням.'; + + @override + String get wizardSetupBadge => 'Налаштування'; + + @override + String get wizardOverviewBadge => 'Огляд'; + + @override + String wizardConnectedToDevice(String deviceName) { + return 'Підключено до $deviceName'; + } + + @override + String get wizardNoDeviceConnected => 'Ще немає підключеного пристрою'; + + @override + String get wizardSkipForNow => 'Пропустити поки що'; + + @override + String get wizardDeviceNameLabel => 'Назва пристрою'; + + @override + String get wizardDeviceNameHelp => + 'Ця назва оголошується іншим користувачам MeshCore.'; + + @override + String get wizardConfigRegionLabel => 'Регіон конфігурації'; + + @override + String get wizardConfigRegionHelp => + 'Використовується повний офіційний список профілів MeshCore. Типово вибрано EU/UK (Narrow).'; + + @override + String get wizardPresetNote1 => + 'Переконайтеся, що вибраний профіль відповідає місцевим радіоправилам.'; + + @override + String get wizardPresetNote2 => + 'Список відповідає офіційному потоку профілів інструмента MeshCore config.'; + + @override + String get wizardPresetNote3 => + 'Для онбордингу типовим залишається EU/UK (Narrow).'; + + @override + String get wizardSaving => 'Збереження...'; + + @override + String get wizardSaveAndContinue => 'Зберегти й продовжити'; + + @override + String get wizardEnterDeviceName => + 'Введіть назву пристрою перед продовженням.'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return 'Збережено $deviceName з профілем $presetName.'; + } + + @override + String get wizardNetworkTitle => 'Контакти, кімнати та ретранслятори'; + + @override + String get wizardNetworkDescription => + 'Вкладка «Контакти» організовує мережу, яку ви виявляєте, і маршрути, які вивчаєте з часом.'; + + @override + String get wizardNetworkFeature1 => + 'Переглядайте членів команди, ретранслятори, кімнати, канали та оголошення в очікуванні в одному списку.'; + + @override + String get wizardNetworkFeature2 => + 'Використовуйте smart ping, вхід до кімнат, вивчені шляхи й інструменти скидання маршрутів, коли зв\'язок стає нестабільним.'; + + @override + String get wizardNetworkFeature3 => + 'Створюйте канали та керуйте мережевими призначеннями, не виходячи з програми.'; + + @override + String get wizardMapOpsTitle => 'Мапа, сліди та спільна геометрія'; + + @override + String get wizardMapOpsDescription => + 'Мапа програми напряму пов\'язана з повідомленнями, відстеженням і SAR-накладками, а не є окремим переглядачем.'; + + @override + String get wizardMapOpsFeature1 => + 'Відстежуйте власну позицію, місця розташування команди та сліди руху на мапі.'; + + @override + String get wizardMapOpsFeature2 => + 'Відкривайте малюнки з повідомлень, переглядайте їх в інтерфейсі та видаляйте з мапи за потреби.'; + + @override + String get wizardMapOpsFeature3 => + 'Використовуйте мапи ретрансляторів і спільні накладки, щоб розуміти покриття мережі в полі.'; + + @override + String get wizardToolsTitle => 'Інструменти поза повідомленнями'; + + @override + String get wizardToolsDescription => + 'Тут є більше, ніж чотири основні вкладки. Програма також включає налаштування, діагностику та необов\'язкові сценарії датчиків.'; + + @override + String get wizardToolsFeature1 => + 'Відкрийте налаштування пристрою, щоб змінити параметри радіо, телеметрію, потужність TX і дані супутнього пристрою.'; + + @override + String get wizardToolsFeature2 => + 'Увімкніть вкладку «Датчики», коли потрібні панелі моніторингу та швидкі дії оновлення.'; + + @override + String get wizardToolsFeature3 => + 'Використовуйте журнали пакетів, сканування спектра та діагностику розробника для усунення проблем mesh-мережі.'; + + @override + String get postConnectDiscoveryTitle => 'Виявити ретранслятори зараз?'; + + @override + String get postConnectDiscoveryDescription => + 'Запустіть виявлення ретрансляторів одразу після підключення, щоб побачити найближчі вузли MeshCore та швидше додати їх до своєї мережі.'; + + @override + String get contactInSensors => 'У датчиках'; + + @override + String get contactAddToSensors => 'Додати до датчиків'; + + @override + String get contactSetPath => 'Задати шлях'; + + @override + String contactAddedToSensors(String contactName) { + return '$contactName додано до Датчиків'; + } + + @override + String contactFailedToClearRoute(String error) { + return 'Не вдалося очистити маршрут: $error'; + } + + @override + String get contactRouteCleared => 'Маршрут очищено'; + + @override + String contactRouteSet(String route) { + return 'Маршрут задано: $route'; + } + + @override + String contactFailedToSetRoute(String error) { + return 'Не вдалося задати маршрут: $error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'Тайм-аут ACK'; + + @override + String get opcode => 'Опкод'; + + @override + String get payload => 'Корисне навантаження'; + + @override + String get hops => 'Хопи'; + + @override + String get hashSize => 'Розмір хешу'; + + @override + String get pathBytes => 'Байти шляху'; + + @override + String get selectedPath => 'Обраний шлях'; + + @override + String get estimatedTx => 'Очікувана передача'; + + @override + String get senderToReceipt => 'Від відправника до отримання'; + + @override + String get receivedCopies => 'Отримані копії'; + + @override + String get retryCause => 'Причина повтору'; + + @override + String get retryMode => 'Режим повтору'; + + @override + String get retryResult => 'Результат повтору'; + + @override + String get lastRetry => 'Остання спроба'; + + @override + String get rxPackets => 'RX-пакети'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => 'Швидкість'; + + @override + String get window => 'Вікно'; + + @override + String get posttxDelay => 'Затримка після передачі'; + + @override + String get bandpass => 'Смуговий'; + + @override + String get bandpassFilterVoice => 'Смуговий фільтр голосу'; + + @override + String get active => 'Активно'; + + @override + String get addContact => 'Додати контакт'; + + @override + String get all => 'Усі'; + + @override + String get clearAllLabel => 'Очистити все'; + + @override + String get clearFilters => 'Очистити фільтри'; + + @override + String get clearRoute => 'Очистити маршрут'; + + @override + String get clearMessages => 'Очистити повідомлення'; + + @override + String get clearScale => 'Очистити масштаб'; + + @override + String get clearDiscoveries => 'Очистити виявлення'; + + @override + String get clearOnlineTraceDatabase => 'Очистити базу трасувань'; + + @override + String get clearAllChannels => 'Очистити всі канали'; + + @override + String get clearAllContacts => 'Очистити всі контакти'; + + @override + String get clearChannels => 'Очистити канали'; + + @override + String get clearContacts => 'Очистити контакти'; + + @override + String get clearPathOnMaxRetry => 'Очистити шлях при макс. спробі'; + + @override + String get create => 'Створити'; + + @override + String get custom => 'Користувацький'; + + @override + String get defaultValue => 'За замовчуванням'; + + @override + String get duplicate => 'Дублювати'; + + @override + String get editName => 'Редагувати ім\'я'; + + @override + String get open => 'Відкрити'; + + @override + String get paste => 'Вставити'; + + @override + String get preview => 'Попередній перегляд'; + + @override + String get remove => 'Видалити'; + + @override + String get rename => 'Перейменувати'; + + @override + String get resolveAll => 'Вирішити все'; + + @override + String get send => 'Надіслати'; + + @override + String get sendAnyway => 'Надіслати все одно'; + + @override + String get share => 'Поділитися'; + + @override + String get trace => 'Трасування'; + + @override + String get discovery => 'Виявлення'; + + @override + String get discoverRepeaters => 'Виявити повторювачі'; + + @override + String get discoverSensors => 'Виявити датчики'; + + @override + String get repeaterDiscoverySent => 'Виявлення повторювачів надіслано'; + + @override + String get sensorDiscoverySent => 'Виявлення датчиків надіслано'; + + @override + String get clearedPendingDiscoveries => 'Очікувані виявлення очищено.'; + + @override + String get autoDiscovery => 'Автоматичне виявлення'; + + @override + String get enableAutomaticAdding => 'Увімкнути автоматичне додавання'; + + @override + String get autoaddRepeaters => 'Автододавання повторювачів'; + + @override + String get autoaddRoomServers => 'Автододавання серверів кімнат'; + + @override + String get autoaddSensors => 'Автододавання датчиків'; + + @override + String get autoaddUsers => 'Автододавання користувачів'; + + @override + String get overwriteOldestWhenFull => + 'Перезаписати найстаріші при заповненні'; + + @override + String get storage => 'Сховище'; + + @override + String get dangerZone => 'Небезпечна зона'; + + @override + String get profiles => 'Профілі'; + + @override + String get favourites => 'Обране'; + + @override + String get sensors => 'Датчики'; + + @override + String get others => 'Інші'; + + @override + String get gpsModule => 'Модуль GPS'; + + @override + String get liveTraffic => 'Живий трафік'; + + @override + String get repeatersMap => 'Карта повторювачів'; + + @override + String get blePacketLogs => 'Журнали BLE-пакетів'; + + @override + String get onlineTraceDatabase => 'База трасувань'; + + @override + String get routePathByteSize => 'Розмір шляху в байтах'; + + @override + String get messageNotifications => 'Сповіщення про повідомлення'; + + @override + String get sarAlerts => 'SAR-сповіщення'; + + @override + String get discoveryNotifications => 'Сповіщення про виявлення'; + + @override + String get updateNotifications => 'Сповіщення про оновлення'; + + @override + String get muteWhileAppIsOpen => 'Без звуку при відкритому додатку'; + + @override + String get disableContacts => 'Вимкнути контакти'; + + @override + String get enableSensorsTab => 'Увімкнути вкладку Датчики'; + + @override + String get enableProfiles => 'Увімкнути профілі'; + + @override + String get nearestRepeaterFallback => 'Найближчий повторювач як резерв'; + + @override + String get deleteAllStoredMessageHistory => + 'Видалити всю історію повідомлень'; + + @override + String get messageFontSize => 'Розмір шрифту повідомлень'; + + @override + String get rotateMapWithHeading => 'Обертати карту за напрямком'; + + @override + String get showMapDebugInfo => 'Показати налагоджувальну інфо карти'; + + @override + String get openMapInFullscreen => 'Відкрити карту на повний екран'; + + @override + String get showSarMarkersLabel => 'Показати SAR-маркери'; + + @override + String get hideRepeatersOnMap => 'Сховати повторювачі на карті'; + + @override + String get setMapScale => 'Встановити масштаб карти'; + + @override + String get customMapScaleSaved => 'Користувацький масштаб карти збережено'; + + @override + String get voiceBitrate => 'Бітрейт голосу'; + + @override + String get voiceCompressor => 'Компресор голосу'; + + @override + String get voiceLimiter => 'Лімітер голосу'; + + @override + String get micAutoGain => 'Автопідсилення мікрофона'; + + @override + String get echoCancellation => 'Придушення ехо'; + + @override + String get noiseSuppression => 'Придушення шуму'; + + @override + String get trimSilenceInVoiceMessages => + 'Обрізати тишу в голосових повідомленнях'; + + @override + String get compressor => 'Компресор'; + + @override + String get limiter => 'Лімітер'; + + @override + String get autoGain => 'Автопідсилення'; + + @override + String get echoCancel => 'Ехо'; + + @override + String get noiseSuppress => 'Шум'; + + @override + String get silenceTrim => 'Тиша'; + + @override + String get maxImageSize => 'Максимальний розмір зображення'; + + @override + String get imageCompression => 'Стиснення зображення'; + + @override + String get grayscale => 'Відтінки сірого'; + + @override + String get ultraMode => 'Ультра режим'; + + @override + String get fastPrivateGpsUpdates => 'Швидкі приватні GPS-оновлення'; + + @override + String get movementThreshold => 'Поріг руху'; + + @override + String get fastGpsMovementThreshold => 'Поріг руху швидкого GPS'; + + @override + String get fastGpsActiveuseInterval => + 'Інтервал активного використання швидкого GPS'; + + @override + String get activeuseUpdateInterval => + 'Інтервал оновлення при активному використанні'; + + @override + String get repeatNearbyTraffic => 'Повторювати близький трафік'; + + @override + String get relayThroughRepeatersAcrossTheMesh => + 'Ретранслювати через повторювачі по мережі'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => + 'Лише поблизу, без flood повторювачів'; + + @override + String get multihop => 'Багатохоповий'; + + @override + String get createProfile => 'Створити профіль'; + + @override + String get renameProfile => 'Перейменувати профіль'; + + @override + String get newProfile => 'Новий профіль'; + + @override + String get manageProfiles => 'Керувати профілями'; + + @override + String get enableProfilesToStartManagingThem => + 'Увімкніть профілі, щоб почати керувати ними.'; + + @override + String get openMessage => 'Відкрити повідомлення'; + + @override + String get jumpToTheRelatedSarMessage => + 'Перейти до пов\'язаного SAR-повідомлення'; + + @override + String get removeSarMarker => 'Видалити SAR-маркер'; + + @override + String get pleaseSelectADestinationToSendSarMarker => + 'Виберіть призначення для надсилання SAR-маркера'; + + @override + String get sarMarkerBroadcastToPublicChannel => + 'SAR-маркер надіслано на публічний канал'; + + @override + String get sarMarkerSentToRoom => 'SAR-маркер надіслано в кімнату'; + + @override + String get loadFromGallery => 'Завантажити з галереї'; + + @override + String get replaceImage => 'Замінити зображення'; + + @override + String get selectFromGallery => 'Вибрати з галереї'; + + @override + String get team => 'Команда'; + + @override + String get found => 'Знайдено'; + + @override + String get staging => 'Місце збору'; + + @override + String get object => 'Об\'єкт'; + + @override + String get quiet => 'Тихо'; + + @override + String get busy => 'Зайнято'; + + @override + String get searchMessages => 'Пошук повідомлень'; + + @override + String get sendImageFromGallery => 'Надіслати зображення з галереї'; + + @override + String get takePhoto => 'Зробити фото'; + + @override + String get allMessages => 'Усі повідомлення'; + + @override + String get sendToPublicChannel => 'Надіслати в публічний канал?'; + + @override + String get selectMarkerTypeAndDestination => + 'Виберіть тип маркера та призначення'; + + @override + String get noDestinationsAvailableLabel => 'Немає доступних призначень'; + + @override + String get image => 'Зображення'; + + @override + String get format => 'Формат'; + + @override + String get dimensions => 'Розміри'; + + @override + String get segments => 'Сегменти'; + + @override + String get transfers => 'Передачі'; + + @override + String get downloadedBy => 'Завантажено'; + + @override + String get saveDiscoverySettings => 'Зберегти налаштування виявлення'; + + @override + String get savePublicInfo => 'Зберегти публічну інформацію'; + + @override + String get saveRadioSettings => 'Зберегти налаштування радіо'; + + @override + String get savePath => 'Зберегти шлях'; + + @override + String get wipeDeviceData => 'Стерти дані пристрою'; + + @override + String get wipeDevice => 'Стерти пристрій'; + + @override + String get destructiveDeviceActions => 'Деструктивні дії пристрою.'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => + 'Оберіть пресет або налаштуйте радіо вручну.'; + + @override + String get used => 'Використано'; + + @override + String get total => 'Загалом'; + + @override + String get renameValue => 'Перейменувати значення'; + + @override + String get customizeFields => 'Налаштувати поля'; + + @override + String get livePreview => 'Попередній перегляд наживо'; + + @override + String get refreshSchedule => 'Розклад оновлення'; + + @override + String get noResponse => 'Немає відповіді'; + + @override + String get refreshing => 'Оновлення'; + + @override + String get unavailable => 'Недоступно'; + + @override + String get pickARelayOrNodeToWatchInSensors => + 'Оберіть реле або вузол для спостереження.'; + + @override + String get publicKeyLabel => 'Публічний ключ'; + + @override + String get alreadyInContacts => 'Вже в контактах'; + + @override + String get connectToADeviceBeforeAddingContacts => + 'Підключіться до пристрою перед додаванням контактів'; + + @override + String get fromContacts => 'З контактів'; + + @override + String get onlineOnly => 'Лише онлайн'; + + @override + String get inBoth => 'В обох'; + + @override + String get source => 'Джерело'; + + @override + String get allMessagesCleared => 'Усі повідомлення очищено'; + + @override + String get onlineTraceDatabaseCleared => 'Базу трасувань очищено'; + + @override + String get packetLogsCleared => 'Журнали пакетів очищено'; + + @override + String get hexDataCopiedToClipboard => 'Hex-дані скопійовано в буфер'; + + @override + String get developerModeEnabled => 'Режим розробника увімкнено'; + + @override + String get developerModeDisabled => 'Режим розробника вимкнено'; + + @override + String get clipboardIsEmpty => 'Буфер обміну порожній'; + + @override + String get contactImported => 'Контакт імпортовано'; + + @override + String get contactLinkCopiedToClipboard => + 'Посилання на контакт скопійовано в буфер'; + + @override + String get failedToExportContact => 'Не вдалося експортувати контакт'; + + @override + String get noLogsToExport => 'Немає журналів для експорту'; + + @override + String get exportAsCsv => 'Експортувати як CSV'; + + @override + String get exportAsText => 'Експортувати як текст'; + + @override + String get receivedRfc3339 => 'Отримано (RFC3339)'; + + @override + String get buildTime => 'Час збірки'; + + @override + String get downloadUrlNotAvailable => 'URL завантаження недоступний'; + + @override + String get cannotOpenDownloadUrl => 'Не вдається відкрити URL завантаження'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => + 'Перевірка оновлень доступна лише на Android'; + + @override + String get youAreRunningTheLatestVersion => + 'Ви використовуєте останню версію'; + + @override + String get updateAvailableButDownloadUrlNotFound => + 'Оновлення доступне, але URL завантаження не знайдено'; + + @override + String get startTictactoe => 'Почати Tic-Tac-Toe'; + + @override + String get tictactoeUnavailable => 'Tic-Tac-Toe недоступно'; + + @override + String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: суперник невідомий'; + + @override + String get tictactoeWaitingForStart => 'Tic-Tac-Toe: очікування початку'; + + @override + String get acceptsShareLinks => 'Приймає посилання для обміну'; + + @override + String get supportsRawHex => 'Підтримує raw hex'; + + @override + String get clipboardfriendly => 'Зручно для буфера'; + + @override + String get captured => 'Захоплено'; + + @override + String get size => 'Розмір'; + + @override + String get noCustomChannelsToClear => + 'Немає користувацьких каналів для очищення.'; + + @override + String get noDeviceContactsToClear => + 'Немає контактів пристрою для очищення.'; + + @override + String get aToZ => 'А-Я'; + + @override + String get add => 'Додати'; + + @override + String get addAdditionalDetails => 'Додати додаткові деталі'; + + @override + String get addPeer => 'Додати вузол'; + + @override + String get addPeerManually => 'Додати вузол вручну'; + + @override + String addedToContacts(String name) { + return '$name додано до контактів'; + } + + @override + String addedToSensors(String name) { + return '$name додано до сенсорів'; + } + + @override + String get allowAll => 'Дозволити все'; + + @override + String get anonymousRxStats => 'Анонімна статистика RX'; + + @override + String get appearance => 'Зовнішній вигляд'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => 'Базова телеметрія'; + + @override + String get blePin => 'BLE PIN'; + + @override + String get buzzerAlerts => 'Сповіщення зумером'; + + @override + String get cancelImageReceive => 'Скасувати отримання зображення'; + + @override + String get choose => 'Обрати'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => + 'Оберіть, які вкладки та розділи контактів показувати'; + + @override + String get clearCache => 'Очистити кеш'; + + @override + String get clearHistory => 'Очистити історію'; + + @override + String get clearLiveView => 'Очистити живий перегляд'; + + @override + String get clearLogs => 'Очистити журнали'; + + @override + String get clearOfflineCache => 'Очистити офлайн-кеш?'; + + @override + String get clearOverlay => 'Очистити накладання'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => + 'Очистити маршрут лише після невдачі всіх спроб'; + + @override + String get codecBitrateAndAudioProcessing => + 'Кодек, бітрейт та обробка звуку'; + + @override + String get connectByIpAddress => 'Підключити за IP-адресою'; + + @override + String get controlWhichAlertsYouReceive => + 'Керуйте сповіщеннями, які ви отримуєте'; + + @override + String get copyFullHex => 'Копіювати повний Hex'; + + @override + String get copyRawResponse => 'Копіювати необроблену відповідь'; + + @override + String get couldNotLoadMetHistory => 'Не вдалося завантажити історію MET'; + + @override + String customizeSensor(String name) { + return 'Налаштувати $name'; + } + + @override + String get data => 'Дані'; + + @override + String get deleteGroup => 'Видалити групу'; + + @override + String deleteStyleConfirm(String name) { + return 'Видалити $name?'; + } + + @override + String deleteThisDrawing(String type) { + return 'Видалити цей $type?'; + } + + @override + String get deny => 'Відхилити'; + + @override + String get deviceInfo => 'Інформація про пристрій'; + + @override + String get deviceSettings => 'Налаштування пристрою'; + + @override + String get deviceTimeSynced => 'Час пристрою синхронізовано з цим телефоном.'; + + @override + String get disableFastGpsPublishing => 'Вимкнути швидку публікацію GPS'; + + @override + String get displayMarkersAndTraceDatabase => + 'Відображення, маркери та база даних трасувань'; + + @override + String get download => 'Завантажити'; + + @override + String get environmentalTelemetry => 'Телеметрія довкілля'; + + @override + String errorCheckingForUpdates(String error) { + return 'Помилка перевірки оновлень: $error'; + } + + @override + String errorGeneric(String error) { + return 'Помилка: $error'; + } + + @override + String errorOpeningDownload(String error) { + return 'Помилка відкриття завантаження: $error'; + } + + @override + String get exportLogs => 'Експортувати журнали'; + + @override + String get extraAggressiveAvifCompression => 'Надагресивне стиснення AVIF'; + + @override + String failedToAddContact(String error) { + return 'Не вдалося додати контакт: $error'; + } + + @override + String failedToAddName(String name, String error) { + return 'Не вдалося додати $name: $error'; + } + + @override + String failedToClearChannels(String error) { + return 'Не вдалося очистити канали: $error'; + } + + @override + String failedToClearContacts(String error) { + return 'Не вдалося очистити контакти: $error'; + } + + @override + String get failedToConnectViaSerial => + 'Не вдалося підключитися через послідовний порт'; + + @override + String failedToLoadPreviewImage(String error) { + return 'Не вдалося завантажити попередній перегляд: $error'; + } + + @override + String failedToLoadTrace(String error) { + return 'Не вдалося завантажити трасування: $error'; + } + + @override + String failedToSendSarMarker(String error) { + return 'Не вдалося надіслати SAR маркер: $error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return 'Не вдалося встановити режим зумера: $error'; + } + + @override + String failedToSetGpsMode(String error) { + return 'Не вдалося встановити режим GPS: $error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return 'Не вдалося синхронізувати час пристрою: $error'; + } + + @override + String failedToWipeDeviceData(String error) { + return 'Не вдалося стерти дані пристрою: $error'; + } + + @override + String get fastGpsTargetChannel => 'Цільовий канал швидкого GPS'; + + @override + String get fastUpdatesThresholdsAndPermissions => + 'Швидкі оновлення, пороги та дозволи'; + + @override + String get fetchingCatalog => 'Завантаження каталогу...'; + + @override + String get fifteenMinutes => '15 хвилин'; + + @override + String get fifteenSeconds => '15 секунд'; + + @override + String get filterByDirection => 'Фільтрувати за напрямком'; + + @override + String get finish => 'Завершити'; + + @override + String get fiveMinutes => '5 хвилин'; + + @override + String get fiveSeconds => '5 секунд'; + + @override + String get fix => 'Фіксація'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'Політика оголошень GPS'; + + @override + String get gpsAndLocation => 'GPS та місцезнаходження'; + + @override + String get gpsDiagnostics => 'Діагностика GPS'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => 'Допомога'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => 'Приховано'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => + 'Як радіо автоматично додає виявлені вузли.'; + + @override + String get images => 'Зображення'; + + @override + String get importProfile => 'Імпортувати профіль'; + + @override + String get lastFix => 'Остання фіксація'; + + @override + String get latest => 'Найновіші'; + + @override + String get latitude => 'Широта'; + + @override + String get loadASavedRegion => 'Завантажити збережений регіон'; + + @override + String get loading => 'Завантаження'; + + @override + String get locationSharingHardwareAndUpdateInterval => + 'Обмін місцезнаходженням, обладнання та інтервал оновлення.'; + + @override + String get locationTelemetry => 'Телеметрія місцезнаходження'; + + @override + String get lockDestination => 'Зафіксувати призначення'; + + @override + String get longitude => 'Довгота'; + + @override + String get mapStyle => 'Стиль мапи'; + + @override + String get max => 'Макс'; + + @override + String get maxZoom => 'Макс. масштаб'; + + @override + String get messaging => 'Обмін повідомленнями'; + + @override + String get metHistory => 'Історія MET'; + + @override + String get min => 'Мін'; + + @override + String get minZoom => 'Мін. масштаб'; + + @override + String get moveDown => 'Перемістити вниз'; + + @override + String get moveUp => 'Перемістити вгору'; + + @override + String get multiAckMode => 'Режим мульти-ACK'; + + @override + String get multiDeviceWorkspaceManagement => + 'Керування робочим простором кількох пристроїв'; + + @override + String get myLocation => 'Моє місцезнаходження'; + + @override + String get nameAndTelemetryShared => + 'Ім\'я та телеметрія надсилаються іншим пристроям.'; + + @override + String newVersionAvailable(String version) { + return 'Нова версія: $version'; + } + + @override + String get newer => 'Новіші'; + + @override + String get noNeighboursFound => 'Сусідів не знайдено'; + + @override + String get noServersFound => 'Серверів не знайдено'; + + @override + String get notSet => 'Не встановлено'; + + @override + String get notifications => 'Сповіщення'; + + @override + String get offZeroSeconds => 'Вимк (0 с)'; + + @override + String get offlineMaps => 'Офлайн-мапи'; + + @override + String get older => 'Старіші'; + + @override + String get oneByteMode0 => '1 байт (режим 0)'; + + @override + String get oneHour => '1 година'; + + @override + String get oneMinute => '1 хвилина'; + + @override + String get openPacketLogs => 'Відкрити журнали пакетів'; + + @override + String get packetTypeHelp => 'Довідка типів пакетів'; + + @override + String get pasteShareLinkOrHexAdvert => + 'Вставте посилання або hex-оголошення'; + + @override + String get pathHash => 'Хеш шляху'; + + @override + String get pathSize => 'Розмір шляху'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => 'Полігон'; + + @override + String get rawResponseCopied => 'Необроблену відповідь скопійовано'; + + @override + String get resendThroughNearestRepeaterOnFailure => + 'Повторно надіслати через найближчий ретранслятор у разі невдачі'; + + @override + String get resolveContact => 'Розв\'язати контакт'; + + @override + String get routingRetriesAndDestinationLock => + 'Маршрутизація, повторні спроби та фіксація призначення'; + + @override + String get samples => 'Зразки'; + + @override + String get satellites => 'Супутники'; + + @override + String get saving => 'Збереження...'; + + @override + String get search => 'Пошук'; + + @override + String get searchByNameOrToken => 'Шукати за іменем або токеном'; + + @override + String get searchDiscoveredNodes => 'Шукати виявлені вузли'; + + @override + String get searchInCurrentFilter => 'Шукати в поточному фільтрі'; + + @override + String get searchLogs => 'Шукати в журналах...'; + + @override + String get searchSensors => 'Шукати сенсори'; + + @override + String get searchThisSection => 'Шукати в цьому розділі'; + + @override + String get selectedRelays => 'Обрані ретранслятори'; + + @override + String get sensor => 'Сенсор'; + + @override + String serialError(String error) { + return 'Помилка послідовного порту: $error'; + } + + @override + String setPathForContact(String name) { + return 'Встановити шлях для $name'; + } + + @override + String get shareLiveGps => 'Поділитися GPS у реальному часі'; + + @override + String get shareMyTiles => 'Поділитися моїми тайлами'; + + @override + String get sharingLocationFromPhone => + 'Обмін місцезнаходженням на цьому каналі з телефону.'; + + @override + String get sharingLocationFromRadio => + 'Обмін місцезнаходженням на цьому каналі з радіо.'; + + @override + String signalDbm(String rssi) { + return 'Сигнал $rssi дБм'; + } + + @override + String get sizeCompressionAndPreview => + 'Розмір, стиснення та попередній перегляд'; + + @override + String get simpleMode => 'Простий режим'; + + @override + String get simpleModeDescription => 'Лише чат і карта з обраними контактами'; + + @override + String get simpleModeRequiresLockedDestination => + 'Спочатку увімкніть «Зафіксувати призначення» з каналом, щоб використовувати простий режим'; + + @override + String get noFavouriteContacts => 'Немає обраних контактів'; + + @override + String get skip => 'Пропустити'; + + @override + String get smallerFileSize => 'Менший розмір файлу'; + + @override + String get sort => 'Сортувати'; + + @override + String get stoppedSharingLocation => + 'Обмін місцезнаходженням на цьому каналі зупинено.'; + + @override + String get suppressNotificationsWhileInForeground => + 'Придушити сповіщення, коли застосунок на передньому плані'; + + @override + String get tabsAndNavigation => 'Вкладки та навігація'; + + @override + String get templatesAndTutorials => 'Шаблони та посібники'; + + @override + String get tenMinutes => '10 хвилин'; + + @override + String get tenSeconds => '10 секунд'; + + @override + String get testSendUpdate => 'Тестове надсилання оновлення'; + + @override + String get themeLanguageAndDisplayPreferences => + 'Тема, мова та налаштування відображення'; + + @override + String get thirtyMinutes => '30 хвилин'; + + @override + String get thirtySeconds => '30 секунд'; + + @override + String get threeBytesMode2 => '3 байти (режим 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => + 'Статистика трафіку, історія повідомлень та інструменти розробника'; + + @override + String get twoBytesMode1 => '2 байти (режим 1)'; + + @override + String get twoMinutes => '2 хвилини'; + + @override + String get undo => 'Скасувати'; + + @override + String get updated => 'Оновлено'; + + @override + String get useContactFlags => 'Використовувати прапорці контактів'; + + @override + String get useSavedCoordinates => 'Використати збережені координати'; + + @override + String get view => 'Перегляд'; + + @override + String get viewNeighbours => 'Переглянути сусідів'; + + @override + String get viewPublicStats => 'Переглянути публічну статистику'; + + @override + String get setRegionScope => 'Встановити область регіону'; + + @override + String get regionScope => 'Область регіону'; + + @override + String get regionScopeNone => 'Немає (глобально)'; + + @override + String get clearRegionScope => 'Очистити область'; + + @override + String get regionScopeWarning => + 'Лише ретранслятори, що дозволяють цей регіон, будуть пересилати.'; + + @override + String get discoverRegions => 'Виявити з ретрансляторів'; + + @override + String get discoveringRegions => 'Виявлення регіонів...'; + + @override + String get enterRegionName => 'Назва регіону (напр. auckland)'; + + @override + String get noRegionsFound => 'Регіони не знайдені на цьому ретрансляторі.'; + + @override + String regionScopeSet(String name) { + return 'Область регіону встановлена на $name'; + } + + @override + String get regionScopeCleared => 'Область регіону очищена'; + + @override + String get selectDestinationFirst => + 'Спершу виберіть канал, контакт або кімнату'; + + @override + String get recipientNoLongerAvailable => 'Одержувач більше недоступний'; + + @override + String failedToSendMessage(String error) { + return 'Не вдалося надіслати: $error'; + } + + @override + String get tictactoeDirectOnly => + 'Хрестики-нулики працюють лише в особистих повідомленнях. Спершу виберіть контакт.'; + + @override + String get deviceKeyUnavailable => 'Ключ пристрою недоступний'; + + @override + String get imageCompressionFailed => 'Не вдалося стиснути зображення'; + + @override + String get imageFragmentationFailed => + 'Не вдалося розділити зображення на фрагменти'; + + @override + String get failedToAnnounceImage => 'Не вдалося анонсувати зображення'; + + @override + String get noRecipientSelected => 'Одержувача не вибрано'; + + @override + String get imageSendFailed => 'Не вдалося надіслати зображення'; + + @override + String get microphonePermissionRequiredForVoice => + 'Для голосових повідомлень потрібен дозвіл на мікрофон'; + + @override + String get recordingDiscarded => 'Запис відхилено'; + + @override + String publicChannelMediaWarning(String mediaType) { + return 'Ви збираєтеся надіслати $mediaType в публічний канал. Це не рекомендується: отримати це може будь-хто в mesh-мережі. Виберіть приватний або тегований канал, якщо тільки це не те, що ви хочете.'; + } + + @override + String get selectPrivateChannelToShareLocation => + 'Виберіть приватний канал, щоб поділитися місцезнаходженням.'; + + @override + String get stopSharingMyLocation => 'Припинити ділитися місцезнаходженням'; + + @override + String get shareMyLocation => 'Поділитися місцезнаходженням'; + + @override + String get stopRecording => 'Зупинити запис'; + + @override + String get recordVoice => 'Записати голосове повідомлення'; + + @override + String get moreActions => 'Інші дії'; + + @override + String get noMessagesInFilter => 'У цьому фільтрі немає повідомлень'; + + @override + String get noMatchesInFilter => 'У цьому фільтрі немає збігів'; + + @override + String syncFailed(String error) { + return 'Помилка синхронізації: $error'; + } + + @override + String get invalidLink => 'Недійсне посилання'; + + @override + String get cannotOpenLink => 'Не вдається відкрити посилання'; + + @override + String get failedToOpenLink => 'Не вдалося відкрити посилання'; + + @override + String get failedToResendMessage => + 'Не вдалося повторно надіслати повідомлення'; + + @override + String retryFailed(String error) { + return 'Повторна спроба не вдалася: $error'; + } + + @override + String get deleteDrawingAndMessage => 'Видалити рисунок і повідомлення'; + + @override + String get lastEchoRelay => 'Останнє відлуння: ретранслятор'; + + @override + String get lastEchoPath => 'Останнє відлуння: шлях'; + + @override + String get lastEchoBytesReport => 'Останнє відлуння: звіт про байти'; + + @override + String get noGpsCoordinatesAvailable => 'Координати GPS недоступні'; + + @override + String get notASarMarker => 'Це не маркер SAR'; + + @override + String failedToSaveTemplate(String error) { + return 'Не вдалося зберегти шаблон: $error'; + } + + @override + String get drawingNotFound => 'Рисунок не знайдено'; + + @override + String get unknownDrawingType => 'Невідомий тип рисунка'; + + @override + String get customMapMarker => 'Власний маркер карти'; + + @override + String customMapPointLabel(String point) { + return 'Точка: $point'; + } + + @override + String mapIdLabel(String id) { + return 'ID карти: $id'; + } + + @override + String get cannotFetchVoice => 'Не вдається отримати голосове повідомлення'; + + @override + String get senderContactUnknown => + 'Контакт відправника невідомий. Спершу синхронізуйте контакти.'; + + @override + String get senderRouteUnknown => + 'Маршрут відправника невідомий. Спершу синхронізуйте контакти/шлях.'; + + @override + String messageTooFar(String hops, String maxHops) { + return 'Повідомлення задалеко ($hops хопів, макс. $maxHops).'; + } + + @override + String get senderRouteNoPathResponse => + 'Маршрут відправника не відповів на перевірку шляху. Синхронізуйте контакти/шлях і спробуйте ще раз.'; + + @override + String get senderRouteNoRawResponse => + 'Маршрут відправника не відповів на raw-транспортному шляху.'; + + @override + String voiceFetchOverHops(String hops) { + return 'Отримання голосу через $hops хопів може зайняти час.'; + } + + @override + String get voiceReceiveCanceled => + 'Приймання голосового повідомлення скасовано'; + + @override + String get fetchingMissingVoiceFragments => + 'Отримання відсутніх фрагментів голосу'; + + @override + String get receivingVoice => 'Приймання голосу'; + + @override + String get cannotFetchImage => 'Не вдається отримати зображення'; + + @override + String imageFetchOverHops(String hops) { + return 'Отримання зображення через $hops хопів може зайняти час.'; + } + + @override + String get imageAlreadyBeingReceived => 'Зображення вже приймається'; + + @override + String get loadImage => 'Завантажити зображення'; + + @override + String get imageFetchFailedToSendRequest => + 'Не вдалося надіслати запит на зображення'; + + @override + String get imageUnavailable => 'Зображення зараз недоступне'; + + @override + String get imageFetchTimedOut => 'Час очікування зображення минув'; + + @override + String get imageReceiveCanceled => 'Приймання зображення скасовано'; + + @override + String get closeImagePreview => 'Закрити перегляд зображення'; + + @override + String get recordingReleaseToSend => 'Запис... відпустіть, щоб надіслати'; + + @override + String get sendingVoice => 'Надсилання голосу...'; + + @override + String get sendLongPressToRecordVoice => + 'Надіслати (утримуйте для запису голосу)'; + + @override + String get discardRecording => 'Відхилити запис'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count хопа', + many: '$count хопів', + few: '$count хопи', + one: '$count хоп', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => + 'Широкомовні канали для mesh-трафіку поблизу'; + + @override + String get roomsSectionDescription => + 'Спільні простори для постійної координації команди'; + + @override + String get contactsSectionDescription => + 'Люди та пристрої в прямій досяжності'; + + @override + String get noRecentChatters => 'Немає нещодавніх співрозмовників'; + + @override + String get public => 'Публічний'; + + @override + String get failedToSendTicTacToeMove => + 'Не вдалося надіслати хід у хрестики-нулики'; + + @override + String get youWon => 'Ви перемогли'; + + @override + String get opponentWon => 'Переміг суперник'; + + @override + String get gameDraw => 'Нічия'; + + @override + String get yourTurn => 'Ваш хід'; + + @override + String get opponentTurn => 'Хід суперника'; + + @override + String get pleaseEnterValidCoordinates => 'Введіть коректні координати'; + + @override + String get locationNotAvailable => 'Місцезнаходження недоступне'; + + @override + String get customCaveMapPoint => 'Власна точка карти печери'; + + @override + String get mapPoint => 'Точка карти'; + + @override + String get fetchingMissingImageFragments => 'Отримання відсутніх фрагментів…'; + + @override + String get loadingImage => 'Завантаження…'; + + @override + String get receivingImage => 'Приймання…'; + + @override + String get tapToLoad => 'Торкніться, щоб завантажити'; + + @override + String get connectDevice => 'Підключити пристрій'; + + @override + String get chooseTransportSubtitle => + 'Виберіть транспорт: Bluetooth, WiFi або послідовний порт'; + + @override + String get ble => 'BLE'; + + @override + String get network => 'Мережа'; + + @override + String get serial => 'Послідовний порт'; + + @override + String get scan => 'Сканувати'; + + @override + String get pressScanToSearchForDevices => + 'Натисніть «Сканувати», щоб знайти пристрої поблизу'; + + @override + String failedToConnectToDevice(String name) { + return 'Не вдалося підключитися до $name'; + } + + @override + String get unknownDevice => 'Невідомий пристрій'; + + @override + String get showingCachedResultsTapRefresh => + 'Показано кешовані результати. Натисніть «Оновити», щоб пересканувати.'; + + @override + String scanningLocalNetworkOnPort(int port) { + return 'Сканування локальної мережі на наявність WiFi-пристроїв MeshCore на порту $port'; + } + + @override + String get cancelScanAndAddServer => 'Скасувати сканування та додати сервер'; + + @override + String get addServer => 'Додати сервер'; + + @override + String scanningProgressIps(int scanned, String total) { + return 'Сканування... $scanned/$total IP-адрес'; + } + + @override + String get cancelAndEnterManually => 'Скасувати та ввести вручну'; + + @override + String get noRecentOrDiscoveredServers => + 'Поки немає нещодавніх або виявлених серверів'; + + @override + String get recentlyUsed => 'Нещодавно використані'; + + @override + String get connecting => 'Підключення...'; + + @override + String get discoveredOnThisNetwork => 'Виявлені в цій мережі'; + + @override + String serverNoLongerAvailable(String host, int port) { + return 'Сервер $host:$port більше недоступний. Виконайте сканування ще раз, щоб знайти активні сервери.'; + } + + @override + String failedToConnectToHost(String host, int port) { + return 'Не вдалося підключитися до $host:$port'; + } + + @override + String get enterValidIpAddress => 'Введіть коректну IP-адресу'; + + @override + String get enterValidTcpPort => 'Введіть коректний TCP-порт'; + + @override + String get ipAddress => 'IP-адреса'; + + @override + String get tcpPort => 'TCP-порт'; + + @override + String get customServerPort => 'Власний порт сервера'; + + @override + String get chat => 'Чат'; + + @override + String get restoringPreviousLink => 'Відновлення попереднього підключення'; + + @override + String get noDeviceConnected => 'Немає підключеного пристрою'; + + @override + String get sendAdvert => 'Надіслати мій контакт'; + + @override + String get advertMode => 'Надіслати мій контакт'; + + @override + String get advertModeSubtitle => + 'Виберіть, як далеко мають поширитися ваші контактні дані.'; + + @override + String get floodAdvertSent => 'Контакт надіслано по всій mesh-мережі'; + + @override + String get directAdvertSent => 'Контакт надіслано пристроям поблизу'; +} diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart new file mode 100644 index 0000000..aaabd01 --- /dev/null +++ b/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,3303 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get appTitle => 'MeshCore SAR'; + + @override + String get messages => '消息'; + + @override + String get contacts => '联系人'; + + @override + String get map => '地图'; + + @override + String get settings => '设置'; + + @override + String get connect => '连接'; + + @override + String get disconnect => '断开连接'; + + @override + String get noDevicesFound => '未找到设备'; + + @override + String get scanAgain => '重新扫描'; + + @override + String get deviceNotConnected => '设备未连接'; + + @override + String get locationPermissionDenied => '位置权限被拒绝'; + + @override + String get locationPermissionPermanentlyDenied => '位置权限被永久拒绝。请在设置中启用。'; + + @override + String get locationPermissionRequired => 'GPS 跟踪和团队协调需要位置权限。您可以稍后在设置中启用。'; + + @override + String get locationServicesDisabled => '位置服务已禁用。请在设置中启用。'; + + @override + String get failedToGetGpsLocation => '获取 GPS 位置失败'; + + @override + String failedToAdvertise(String error) { + return '广播失败: $error'; + } + + @override + String get cancelReconnection => '取消重新连接'; + + @override + String get general => '通用'; + + @override + String get theme => '主题'; + + @override + String get chooseTheme => '选择主题'; + + @override + String get light => '亮色'; + + @override + String get dark => '暗色'; + + @override + String get blueLightTheme => '蓝色亮色主题'; + + @override + String get blueDarkTheme => '蓝色暗色主题'; + + @override + String get sarRed => '搜救红'; + + @override + String get alertEmergencyMode => '警报/紧急模式'; + + @override + String get sarGreen => '搜救绿'; + + @override + String get safeAllClearMode => '安全/解除警报模式'; + + @override + String get autoSystem => '自动 (跟随系统)'; + + @override + String get followSystemTheme => '跟随系统主题'; + + @override + String get showRxTxIndicators => '显示 RX/TX 指示器'; + + @override + String get disableMap => '禁用地图'; + + @override + String get language => '语言'; + + @override + String get chooseLanguage => '选择语言'; + + @override + String get save => '保存'; + + @override + String get cancel => '取消'; + + @override + String get close => '关闭'; + + @override + String get about => '关于'; + + @override + String get appVersion => '应用版本'; + + @override + String get aboutMeshCoreSar => '关于 MeshCore 搜救助手'; + + @override + String get aboutDescription => + '专为应急响应团队设计的搜救应用程序。功能包括:\n\n• 用于设备间通信的 BLE Mesh 网络\n• 带有多个图层选项的离线地图\n• 实时团队成员跟踪\n• 搜救战术标记(找到的人员、火灾、集结区)\n• 联系人管理和消息收发\n• 带有指南针方向的 GPS 跟踪\n• 用于离线使用的地图瓦片缓存'; + + @override + String get technologiesUsed => '使用的技术:'; + + @override + String get technologiesList => + '• 用于跨平台开发的 Flutter\n• 用于 Mesh 网络的 BLE(低功耗蓝牙)\n• 用于地图的 OpenStreetMap\n• 用于状态管理的 Provider\n• 用于本地存储的 SharedPreferences'; + + @override + String get moreInfo => '更多信息'; + + @override + String get packageName => '包名'; + + @override + String get sampleData => '示例数据'; + + @override + String get sampleDataDescription => '加载或清除用于测试的示例联系人、频道消息和搜救标记'; + + @override + String get loadSampleData => '加载示例数据'; + + @override + String get clearAllData => '清除所有数据'; + + @override + String get clearAllDataConfirmTitle => '清除所有数据'; + + @override + String get clearAllDataConfirmMessage => '这将清除所有联系人和搜救标记。您确定吗?'; + + @override + String get clear => '清除'; + + @override + String loadedSampleData( + int teamCount, + int channelCount, + int sarCount, + int messageCount, + ) { + return '已加载 $teamCount 个团队成员,$channelCount 个频道,$sarCount 个搜救标记,$messageCount 条消息'; + } + + @override + String failedToLoadSampleData(String error) { + return '加载示例数据失败:$error'; + } + + @override + String get allDataCleared => '所有数据已清除'; + + @override + String get failedToStartBackgroundTracking => '启动后台跟踪失败。请检查权限和 BLE 连接。'; + + @override + String locationBroadcast(String latitude, String longitude) { + return '位置广播:$latitude, $longitude'; + } + + @override + String get defaultPinInfo => '无屏幕设备的默认 PIN 是 123456。配对有问题?请在系统设置中忘记该蓝牙设备。'; + + @override + String get noMessagesYet => '暂无消息'; + + @override + String get pullDownToSync => '下拉同步消息'; + + @override + String get deleteContact => '删除联系人'; + + @override + String get delete => '删除'; + + @override + String get viewOnMap => '在地图上查看'; + + @override + String get refresh => '刷新'; + + @override + String get resetPath => '重置路径 (重新路由)'; + + @override + String copiedToClipboard(String label) { + return '$label 已复制到剪贴板'; + } + + @override + String get pleaseEnterPassword => '请输入密码'; + + @override + String failedToSyncContacts(String error) { + return '同步联系人失败:$error'; + } + + @override + String get loggedInSuccessfully => '登录成功!正在等待房间消息...'; + + @override + String get loginFailed => '登录失败 - 密码错误'; + + @override + String loggingIn(String roomName) { + return '正在登录到 $roomName...'; + } + + @override + String failedToSendLogin(String error) { + return '发送登录命令失败:$error'; + } + + @override + String get lowLocationAccuracy => '位置精度低'; + + @override + String get continue_ => '继续'; + + @override + String get sendSarMarker => '发送搜救标记'; + + @override + String get deleteDrawing => '删除绘图'; + + @override + String get drawingTools => '绘图工具'; + + @override + String get drawLine => '绘制线条'; + + @override + String get drawLineDesc => '在地图上绘制手绘线条'; + + @override + String get drawRectangle => '绘制矩形'; + + @override + String get drawRectangleDesc => '在地图上绘制矩形区域'; + + @override + String get measureDistance => '测量距离'; + + @override + String get measureDistanceDesc => '长按两个点进行测量'; + + @override + String get clearMeasurement => '清除测量'; + + @override + String distanceLabel(String distance) { + return '距离:$distance'; + } + + @override + String get longPressForSecondPoint => '长按以选择第二个点'; + + @override + String get longPressToStartMeasurement => '长按以设置第一个点'; + + @override + String get longPressToStartNewMeasurement => '长按以开始新的测量'; + + @override + String get shareDrawings => '共享绘图'; + + @override + String get clearAllDrawings => '清除所有绘图'; + + @override + String get completeLine => '完成线条'; + + @override + String broadcastDrawingsToTeam(int count, String plural) { + return '向团队广播 $count 个绘图'; + } + + @override + String removeAllDrawings(int count, String plural) { + return '移除所有 $count 个绘图'; + } + + @override + String deleteAllDrawingsConfirm(int count, String plural) { + return '从地图上删除所有 $count 个绘图吗?'; + } + + @override + String get drawing => '绘图'; + + @override + String shareDrawingsCount(int count, String plural) { + return '共享 $count 个绘图'; + } + + @override + String get showReceivedDrawings => '显示接收到的绘图'; + + @override + String get showingAllDrawings => '显示所有绘图'; + + @override + String get showingOnlyYourDrawings => '仅显示您自己的绘图'; + + @override + String get showSarMarkers => '显示搜救标记'; + + @override + String get showingSarMarkers => '显示搜救标记'; + + @override + String get hidingSarMarkers => '隐藏搜救标记'; + + @override + String get clearAll => '全部清除'; + + @override + String get publicChannel => '公共频道'; + + @override + String get broadcastToAll => '广播给所有附近节点(临时)'; + + @override + String get storedPermanently => '永久存储在房间中'; + + @override + String get notConnectedToDevice => '未连接到设备'; + + @override + String get typeYourMessage => '输入您的消息...'; + + @override + String get quickLocationMarker => '快速位置标记'; + + @override + String get markerType => '标记类型'; + + @override + String get sendTo => '发送至'; + + @override + String get noDestinationsAvailable => '没有可用的目的地。'; + + @override + String get selectDestination => '选择目的地...'; + + @override + String get ephemeralBroadcastInfo => '临时:仅通过无线广播。不存储 - 节点必须在线。'; + + @override + String get persistentRoomInfo => '持久:不可变地存储在房间中。自动同步并离线保留。'; + + @override + String get location => '位置'; + + @override + String get fromMap => '来自地图'; + + @override + String get gettingLocation => '正在获取位置...'; + + @override + String get locationError => '位置错误'; + + @override + String get retry => '重试'; + + @override + String get refreshLocation => '刷新位置'; + + @override + String accuracyMeters(int accuracy) { + return '精度:±$accuracy米'; + } + + @override + String get notesOptional => '备注(可选)'; + + @override + String get addAdditionalInformation => '添加附加信息...'; + + @override + String lowAccuracyWarning(int accuracy) { + return '位置精度为 ±$accuracy米。这可能不足以用于搜救行动。\n\n仍然继续吗?'; + } + + @override + String get loginToRoom => '登录到房间'; + + @override + String get enterPasswordInfo => '输入访问此房间的密码。密码将被保存以供将来使用。'; + + @override + String get password => '密码'; + + @override + String get enterRoomPassword => '输入房间密码'; + + @override + String get loggingInDots => '正在登录...'; + + @override + String get login => '登录'; + + @override + String failedToAddRoom(String error) { + return '无法将房间添加到设备:$error\n\n该房间可能尚未广播。\n请等待房间广播。'; + } + + @override + String get direct => '直连'; + + @override + String get flood => '泛洪'; + + @override + String get autoSend => '自动发送'; + + @override + String get autoSendDescription => '通过当前路径发送。'; + + @override + String get sendDirect => '直接发送'; + + @override + String get sendDirectDescription => '直接发送给此联系人。'; + + @override + String get sendFlood => '泛洪发送'; + + @override + String get sendFloodDescription => '通过所有中继器发送。'; + + @override + String get loggedIn => '已登录'; + + @override + String get noGpsData => '无 GPS 数据'; + + @override + String get distance => '距离'; + + @override + String directPingTimeout(String name) { + return '直连 Ping 超时 - 正在使用泛洪重试 $name...'; + } + + @override + String pingFailed(String name) { + return 'Ping $name 失败 - 未收到响应'; + } + + @override + String deleteContactConfirmation(String name) { + return '您确定要删除“$name”吗?\n\n这将从应用程序和配套的无线电设备中移除该联系人。'; + } + + @override + String failedToRemoveContact(String error) { + return '移除联系人失败:$error'; + } + + @override + String get type => '类型'; + + @override + String get publicKey => '公钥'; + + @override + String get lastSeen => '最后在线'; + + @override + String get yes => '是'; + + @override + String get no => '否'; + + @override + String get permissions => '权限'; + + @override + String get telemetry => '遥测'; + + @override + String get voltage => '电压'; + + @override + String get battery => '电池'; + + @override + String get temperature => '温度'; + + @override + String get humidity => '湿度'; + + @override + String get pressure => '气压'; + + @override + String get gpsTelemetry => 'GPS(遥测)'; + + @override + String get reLoginToRoom => '重新登录到房间'; + + @override + String get heading => '航向'; + + @override + String get elevation => '海拔'; + + @override + String get accuracy => '精度'; + + @override + String get bearing => '方位角'; + + @override + String get direction => '方向'; + + @override + String get filterMarkers => '筛选标记'; + + @override + String get filterMarkersTooltip => '筛选标记'; + + @override + String get contactsFilter => '联系人'; + + @override + String get repeatersFilter => '转发节点'; + + @override + String get sarMarkers => '搜救标记'; + + @override + String get foundPerson => '找到的人员'; + + @override + String get fire => '火灾'; + + @override + String get stagingArea => '集结区'; + + @override + String get showAll => '显示全部'; + + @override + String get locationUnavailable => '位置不可用'; + + @override + String get ahead => '前方'; + + @override + String degreesRight(int degrees) { + return '向右 $degrees°'; + } + + @override + String degreesLeft(int degrees) { + return '向左 $degrees°'; + } + + @override + String latLonFormat(String latitude, String longitude) { + return '纬度:$latitude 经度:$longitude'; + } + + @override + String get noContactsYet => '暂无联系人'; + + @override + String get connectToDeviceToLoadContacts => '连接设备以加载联系人'; + + @override + String get teamMembers => '团队成员'; + + @override + String get repeaters => '转发节点'; + + @override + String get rooms => '房间'; + + @override + String get channels => '频道'; + + @override + String get selectMapLayer => '选择地图图层'; + + @override + String get openStreetMap => 'OpenStreetMap'; + + @override + String get openTopoMap => 'OpenTopoMap'; + + @override + String get esriSatellite => 'ESRI 卫星图'; + + @override + String get googleHybrid => 'Google 混合图'; + + @override + String get googleRoadmap => 'Google 道路图'; + + @override + String get googleTerrain => 'Google 地形图'; + + @override + String get dragToPosition => '拖动以定位'; + + @override + String get createSarMarker => '创建搜救标记'; + + @override + String get compass => '指南针'; + + @override + String get navigationAndContacts => '导航与联系人'; + + @override + String get sarAlert => '搜救警报'; + + @override + String get textCopiedToClipboard => '文本已复制到剪贴板'; + + @override + String get cannotReplySenderMissing => '无法回复:缺少发送者信息'; + + @override + String get cannotReplyContactNotFound => '无法回复:未找到联系人'; + + @override + String get copyText => '复制文本'; + + @override + String get saveAsTemplate => '另存为模板'; + + @override + String get templateSaved => '模板保存成功'; + + @override + String get templateAlreadyExists => '具有此表情符号的模板已存在'; + + @override + String get deleteMessage => '删除消息'; + + @override + String get deleteMessageConfirmation => '您确定要删除此消息吗?'; + + @override + String get shareLocation => '共享位置'; + + @override + String shareLocationText( + String markerInfo, + String lat, + String lon, + String url, + ) { + return '$markerInfo\n\n坐标:$lat, $lon\n\n谷歌地图:$url'; + } + + @override + String get sarLocationShare => '搜救位置'; + + @override + String get justNow => '刚刚'; + + @override + String minutesAgo(int minutes) { + return '$minutes分钟前'; + } + + @override + String hoursAgo(int hours) { + return '$hours小时前'; + } + + @override + String daysAgo(int days) { + return '$days天前'; + } + + @override + String secondsAgo(int seconds) { + return '$seconds秒前'; + } + + @override + String get sending => '发送中...'; + + @override + String get sent => '已发送'; + + @override + String get delivered => '已送达'; + + @override + String deliveredWithTime(int time) { + return '已送达($time毫秒)'; + } + + @override + String get failed => '失败'; + + @override + String get broadcast => '广播'; + + @override + String deliveredToContacts(int delivered, int total) { + return '已送达 $delivered/$total 个联系人'; + } + + @override + String get allDelivered => '全部送达'; + + @override + String get recipientDetails => '收件人详情'; + + @override + String get pending => '等待中'; + + @override + String get sarMarkerFoundPerson => '找到的人员'; + + @override + String get sarMarkerFire => '火灾位置'; + + @override + String get sarMarkerStagingArea => '集结区'; + + @override + String get sarMarkerObject => '发现的物品'; + + @override + String get from => '来自'; + + @override + String get coordinates => '坐标'; + + @override + String get tapToViewOnMap => '点击在地图上查看'; + + @override + String get radioSettings => '无线电设置'; + + @override + String get frequencyMHz => '频率 (MHz)'; + + @override + String get bandwidth => '带宽'; + + @override + String get spreadingFactor => '扩频因子'; + + @override + String get codingRate => '编码率'; + + @override + String get txPowerDbm => '发射功率 (dBm)'; + + @override + String maxPowerDbm(int power) { + return '最大:$power dBm'; + } + + @override + String get you => '您'; + + @override + String exportFailed(String error) { + return '导出失败:$error'; + } + + @override + String importFailed(String error) { + return '导入失败:$error'; + } + + @override + String get unknown => '未知'; + + @override + String get onlineLayers => '在线图层'; + + @override + String get locationTrail => '位置轨迹'; + + @override + String get showTrailOnMap => '在地图上显示轨迹'; + + @override + String get trailVisible => '轨迹在地图上可见'; + + @override + String get trailHiddenRecording => '轨迹已隐藏(仍在记录)'; + + @override + String get duration => '持续时间'; + + @override + String get points => '点数'; + + @override + String get clearTrail => '清除轨迹'; + + @override + String get clearTrailQuestion => '清除轨迹?'; + + @override + String get clearTrailConfirmation => '您确定要清除当前的位置轨迹吗?此操作无法撤销。'; + + @override + String get noTrailRecorded => '尚未记录轨迹'; + + @override + String get startTrackingToRecord => '开始位置跟踪以记录您的轨迹'; + + @override + String get trailControls => '轨迹控制'; + + @override + String get model => '型号'; + + @override + String get version => '版本'; + + @override + String get maxContacts => '最大联系人数量'; + + @override + String get maxChannels => '最大频道数量'; + + @override + String get publicInfo => '公共信息'; + + @override + String get useCurrentLocation => '使用当前位置'; + + @override + String get noneUnknown => '无/未知'; + + @override + String get chatNode => '聊天节点'; + + @override + String get repeater => '转发节点'; + + @override + String get roomChannel => '房间/频道'; + + @override + String typeNumber(int number) { + return '类型 $number'; + } + + @override + String failedToSave(String error) { + return '保存失败:$error'; + } + + @override + String failedToGetLocation(String error) { + return '获取位置失败:$error'; + } + + @override + String get sarTemplates => '搜救模板'; + + @override + String get addTemplate => '添加模板'; + + @override + String get editTemplate => '编辑模板'; + + @override + String get deleteTemplate => '删除模板'; + + @override + String get templateName => '模板名称'; + + @override + String get templateNameHint => '例如 找到的人员'; + + @override + String get templateEmoji => '表情符号'; + + @override + String get emojiRequired => '需要表情符号'; + + @override + String get nameRequired => '需要名称'; + + @override + String get templateDescription => '描述(可选)'; + + @override + String get templateDescriptionHint => '添加上下文信息...'; + + @override + String get templateColor => '颜色'; + + @override + String get previewFormat => '预览(搜救消息格式)'; + + @override + String get importFromClipboard => '导入'; + + @override + String get exportToClipboard => '导出'; + + @override + String deleteTemplateConfirmation(String name) { + return '删除模板“$name”?'; + } + + @override + String get templateAdded => '模板已添加'; + + @override + String get templateUpdated => '模板已更新'; + + @override + String get templateDeleted => '模板已删除'; + + @override + String templatesImported(int count) { + return '已导入 $count 个模板'; + } + + @override + String templatesExported(int count) { + return '已导出 $count 个模板到剪贴板'; + } + + @override + String get resetToDefaults => '恢复默认'; + + @override + String get resetToDefaultsConfirmation => '这将删除所有自定义模板并恢复 4 个默认模板。是否继续?'; + + @override + String get reset => '重置'; + + @override + String get resetComplete => '模板已重置为默认值'; + + @override + String get noTemplates => '没有可用的模板'; + + @override + String get tapAddToCreate => '点击 + 创建您的第一个模板'; + + @override + String get ok => '确定'; + + @override + String get locationPermission => '位置权限'; + + @override + String get checking => '正在检查...'; + + @override + String get locationPermissionGrantedAlways => '已授予(始终)'; + + @override + String get locationPermissionGrantedWhileInUse => '已授予(使用期间)'; + + @override + String get locationPermissionDeniedTapToRequest => '已拒绝 - 点击请求'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => '永久拒绝 - 打开设置'; + + @override + String get locationPermissionDialogContent => + '位置权限被永久拒绝。请在设备设置中启用它,以便使用 GPS 跟踪和位置共享功能。'; + + @override + String get openSettings => '打开设置'; + + @override + String get locationPermissionGranted => '位置权限已授予!'; + + @override + String get locationPermissionRequiredForGps => 'GPS 跟踪和位置共享需要位置权限。'; + + @override + String get locationPermissionAlreadyGranted => '位置权限已被授予。'; + + @override + String get sarNavyBlue => '搜救海军蓝'; + + @override + String get sarNavyBlueDescription => '专业/操作模式'; + + @override + String get selectRecipient => '选择收件人'; + + @override + String get broadcastToAllNearby => '广播给所有附近设备'; + + @override + String get searchRecipients => '搜索收件人...'; + + @override + String get noContactsFound => '未找到联系人'; + + @override + String get noRoomsFound => '未找到房间'; + + @override + String get noRecipientsAvailable => '没有可用的收件人'; + + @override + String get noChannelsFound => '未找到频道'; + + @override + String get newMessage => '新消息'; + + @override + String get channel => '频道'; + + @override + String get samplePoliceLead => '警队负责人'; + + @override + String get sampleDroneOperator => '无人机操作员'; + + @override + String get sampleFirefighterAlpha => '消防员'; + + @override + String get sampleMedicCharlie => '医护人员'; + + @override + String get sampleCommandDelta => '指挥部'; + + @override + String get sampleFireEngine => '消防车'; + + @override + String get sampleAirSupport => '空中支援'; + + @override + String get sampleBaseCoordinator => '基地协调员'; + + @override + String get channelEmergency => '紧急'; + + @override + String get channelCoordination => '协调'; + + @override + String get channelUpdates => '更新'; + + @override + String get sampleTeamMember => '示例团队成员'; + + @override + String get sampleScout => '示例侦察员'; + + @override + String get sampleBase => '示例基地'; + + @override + String get sampleSearcher => '示例搜索员'; + + @override + String get sampleObjectBackpack => ' 发现的背包 - 蓝色'; + + @override + String get sampleObjectVehicle => ' 遗弃车辆 - 请核实车主'; + + @override + String get sampleObjectCamping => ' 发现的露营装备'; + + @override + String get sampleObjectTrailMarker => ' 在小径外发现的路标'; + + @override + String get sampleMsgAllTeamsCheckIn => '所有小队签到'; + + @override + String get sampleMsgWeatherUpdate => '天气更新:晴朗,气温 18°C'; + + @override + String get sampleMsgBaseCamp => '基地营已在集结区建立'; + + @override + String get sampleMsgTeamAlpha => '小队正在前往第 2 区'; + + @override + String get sampleMsgRadioCheck => '无线电检查 - 所有电台回应'; + + @override + String get sampleMsgWaterSupply => '3 号检查点有水源'; + + @override + String get sampleMsgTeamBravo => '小队报告:第 1 区已排查完毕'; + + @override + String get sampleMsgEtaRallyPoint => '预计到达集合点时间:15 分钟'; + + @override + String get sampleMsgSupplyDrop => '已确认 14:00 进行物资空投'; + + @override + String get sampleMsgDroneSurvey => '无人机勘察完成 - 无发现'; + + @override + String get sampleMsgTeamCharlie => '小队请求支援'; + + @override + String get sampleMsgRadioDiscipline => '所有单位:保持无线电纪律'; + + @override + String get sampleMsgUrgentMedical => '紧急:第 4 区需要医疗援助'; + + @override + String get sampleMsgAdultMale => ' 成年男性,意识清醒'; + + @override + String get sampleMsgFireSpotted => '发现火灾 - 坐标即将发送'; + + @override + String get sampleMsgSpreadingRapidly => ' 正在迅速蔓延!'; + + @override + String get sampleMsgPriorityHelicopter => '优先级:需要直升机支援'; + + @override + String get sampleMsgMedicalTeamEnRoute => '医疗队正在前往您的位置'; + + @override + String get sampleMsgEvacHelicopter => '救援直升机预计 10 分钟后到达'; + + @override + String get sampleMsgEmergencyResolved => '紧急情况已解决 - 解除警报'; + + @override + String get sampleMsgEmergencyStagingArea => ' 紧急集结区'; + + @override + String get sampleMsgEmergencyServices => '紧急服务部门已接到通知并正在响应'; + + @override + String get sampleAlphaTeamLead => '小队队长'; + + @override + String get sampleBravoScout => '侦察员'; + + @override + String get sampleCharlieMedic => '医护人员'; + + @override + String get sampleDeltaNavigator => '导航员'; + + @override + String get sampleEchoSupport => '支援'; + + @override + String get sampleBaseCommand => '基地指挥部'; + + @override + String get sampleFieldCoordinator => '现场协调员'; + + @override + String get sampleMedicalTeam => '医疗队'; + + @override + String get mapDrawing => '地图绘制'; + + @override + String get navigateToDrawing => '导航到绘图'; + + @override + String get copyCoordinates => '复制坐标'; + + @override + String get hideFromMap => '在地图上隐藏'; + + @override + String get lineDrawing => '线条绘图'; + + @override + String get rectangleDrawing => '矩形绘图'; + + @override + String get manualCoordinates => '手动输入坐标'; + + @override + String get enterCoordinatesManually => '手动输入坐标'; + + @override + String get latitudeLabel => '纬度'; + + @override + String get longitudeLabel => '经度'; + + @override + String get exampleCoordinates => '例如:46.0569, 14.5058'; + + @override + String get shareDrawing => '共享绘图'; + + @override + String get shareWithAllNearbyDevices => '与所有附近设备共享'; + + @override + String get shareToRoom => '共享到房间'; + + @override + String get sendToPersistentStorage => '发送到持久房间存储'; + + @override + String get deleteDrawingConfirm => '您确定要删除此绘图吗?'; + + @override + String get drawingDeleted => '绘图已删除'; + + @override + String yourDrawingsCount(int count) { + return '您的绘图 ($count)'; + } + + @override + String get shared => '已共享'; + + @override + String get line => '线条'; + + @override + String get rectangle => '矩形'; + + @override + String get updateAvailable => '有可用更新'; + + @override + String get currentVersion => '当前版本'; + + @override + String get latestVersion => '最新版本'; + + @override + String get downloadUpdate => '下载'; + + @override + String get updateLater => '稍后'; + + @override + String get cadastralParcels => '地籍地块'; + + @override + String get forestRoads => '森林道路'; + + @override + String get wmsOverlays => 'WMS 覆盖层'; + + @override + String get hikingTrails => '徒步小径'; + + @override + String get mainRoads => '主要道路'; + + @override + String get houseNumbers => '门牌号'; + + @override + String get fireHazardZones => '火灾危险区'; + + @override + String get historicalFires => '历史火灾'; + + @override + String get firebreaks => '防火隔离带'; + + @override + String get krasFireZones => '喀斯特火灾区'; + + @override + String get placeNames => '地名'; + + @override + String get municipalityBorders => '市镇边界'; + + @override + String get topographicMap => '地形图 1:25000'; + + @override + String get recentMessages => '最近消息'; + + @override + String get addChannel => '添加频道'; + + @override + String get channelName => '频道名称'; + + @override + String get channelNameHint => '例如 救援队阿尔法'; + + @override + String get channelSecret => '频道密钥'; + + @override + String get channelSecretHint => '此频道的共享密码'; + + @override + String get channelSecretHelp => '此密钥必须与需要访问此频道的所有团队成员共享'; + + @override + String get channelTypesInfo => + '哈希频道 (#团队):密钥根据名称自动生成。名称相同 = 跨设备频道相同。\n\n私有频道:使用显式密钥。只有拥有密钥的人才能加入。'; + + @override + String get hashChannelInfo => '哈希频道:密钥将根据频道名称自动生成。任何使用相同名称的人将加入同一个频道。'; + + @override + String get channelNameRequired => '需要频道名称'; + + @override + String get channelNameTooLong => '频道名称必须为 31 个字符或更少'; + + @override + String get channelSecretRequired => '需要频道密钥'; + + @override + String get channelSecretTooLong => '频道密钥必须为 32 个字符或更少'; + + @override + String get invalidAsciiCharacters => '只允许 ASCII 字符'; + + @override + String get channelCreatedSuccessfully => '频道创建成功'; + + @override + String channelCreationFailed(String error) { + return '创建频道失败:$error'; + } + + @override + String get deleteChannel => '删除频道'; + + @override + String deleteChannelConfirmation(String channelName) { + return '您确定要删除频道“$channelName”吗?此操作无法撤销。'; + } + + @override + String get channelDeletedSuccessfully => '频道删除成功'; + + @override + String channelDeletionFailed(String error) { + return '删除频道失败:$error'; + } + + @override + String get createChannel => '创建频道'; + + @override + String get wizardBack => '返回'; + + @override + String get wizardSkip => '跳过'; + + @override + String get wizardNext => '下一步'; + + @override + String get wizardGetStarted => '开始使用'; + + @override + String get wizardWelcomeTitle => '欢迎使用 MeshCore 搜救助手'; + + @override + String get viewWelcomeTutorial => '查看欢迎教程'; + + @override + String get allTeamContacts => '所有团队联系人'; + + @override + String directMessagesInfo(int count) { + return '带有确认消息的直连消息。发送给 $count 名团队成员。'; + } + + @override + String sarMarkerSentToContacts(int count) { + return '搜救标记已发送给 $count 个联系人'; + } + + @override + String get noContactsAvailable => '没有可用的团队联系人'; + + @override + String get reply => '回复'; + + @override + String get technicalDetails => '技术细节'; + + @override + String get messageTechnicalDetails => '消息技术细节'; + + @override + String get linkQuality => '链路质量'; + + @override + String get delivery => '投递'; + + @override + String get status => '状态'; + + @override + String get expectedAckTag => '预期的 ACK 标签'; + + @override + String get roundTrip => '往返时间'; + + @override + String get retryAttempt => '重试尝试'; + + @override + String get floodFallback => '泛洪回退'; + + @override + String get identity => '身份'; + + @override + String get messageId => '消息 ID'; + + @override + String get sender => '发送者'; + + @override + String get senderKey => '发送者密钥'; + + @override + String get recipient => '接收者'; + + @override + String get recipientKey => '接收者密钥'; + + @override + String get voice => '语音'; + + @override + String get voiceId => '语音 ID'; + + @override + String get envelope => '信封'; + + @override + String get sessionProgress => '会话进度'; + + @override + String get complete => '完成'; + + @override + String get rawDump => '原始数据'; + + @override + String get cannotRetryMissingRecipient => '无法重试:缺少接收者信息'; + + @override + String get voiceUnavailable => '当前语音不可用'; + + @override + String get requestingVoice => '正在请求语音'; + + @override + String get device => '设备'; + + @override + String get change => '更改'; + + @override + String get wizardOverviewDescription => + '此应用将 MeshCore 消息、SAR 现场更新、地图功能和设备工具整合到同一处。'; + + @override + String get wizardOverviewFeature1 => '在主消息标签页中发送私聊消息、房间消息和频道消息。'; + + @override + String get wizardOverviewFeature2 => '通过 mesh 网络共享 SAR 标记、地图绘图、语音片段和图像。'; + + @override + String get wizardOverviewFeature3 => '通过 BLE 或 TCP 连接,然后直接在应用中管理配套电台。'; + + @override + String get wizardMessagingTitle => '消息与现场报告'; + + @override + String get wizardMessagingDescription => '这里的消息不只是纯文本。应用已经支持多种操作载荷和传输流程。'; + + @override + String get wizardMessagingFeature1 => '通过一个编辑器发送私聊消息、房间消息和频道流量。'; + + @override + String get wizardMessagingFeature2 => '为常见现场报告创建 SAR 更新和可复用的 SAR 模板。'; + + @override + String get wizardMessagingFeature3 => '传输语音会话和图像,并在界面中显示进度和空口时间估算。'; + + @override + String get wizardConnectDeviceTitle => '连接设备'; + + @override + String get wizardConnectDeviceDescription => + '连接你的 MeshCore 电台,选择名称,并在继续前应用无线电预设。'; + + @override + String get wizardSetupBadge => '设置'; + + @override + String get wizardOverviewBadge => '概览'; + + @override + String wizardConnectedToDevice(String deviceName) { + return '已连接到 $deviceName'; + } + + @override + String get wizardNoDeviceConnected => '尚未连接设备'; + + @override + String get wizardSkipForNow => '暂时跳过'; + + @override + String get wizardDeviceNameLabel => '设备名称'; + + @override + String get wizardDeviceNameHelp => '此名称会广播给其他 MeshCore 用户。'; + + @override + String get wizardConfigRegionLabel => '配置区域'; + + @override + String get wizardConfigRegionHelp => + '使用完整的官方 MeshCore 预设列表。默认值为 EU/UK (Narrow)。'; + + @override + String get wizardPresetNote1 => '请确认所选预设符合你所在地区的无线电规定。'; + + @override + String get wizardPresetNote2 => '该列表与 MeshCore 配置工具的官方预设源保持一致。'; + + @override + String get wizardPresetNote3 => '在引导设置中默认保持选中 EU/UK (Narrow)。'; + + @override + String get wizardSaving => '正在保存...'; + + @override + String get wizardSaveAndContinue => '保存并继续'; + + @override + String get wizardEnterDeviceName => '继续前请输入设备名称。'; + + @override + String wizardDeviceSetupSaved(String deviceName, String presetName) { + return '已保存 $deviceName,配置为 $presetName。'; + } + + @override + String get wizardNetworkTitle => '联系人、房间和中继器'; + + @override + String get wizardNetworkDescription => '联系人标签页会整理你发现的网络以及随着时间学习到的路由。'; + + @override + String get wizardNetworkFeature1 => '在一个列表中查看团队成员、中继器、房间、频道和待处理广播。'; + + @override + String get wizardNetworkFeature2 => '当连接变得混乱时,使用智能 ping、房间登录、已学习路径和路由重置工具。'; + + @override + String get wizardNetworkFeature3 => '无需离开应用即可创建频道并管理网络目的地。'; + + @override + String get wizardMapOpsTitle => '地图、轨迹与共享几何'; + + @override + String get wizardMapOpsDescription => '应用地图直接与消息、跟踪和 SAR 叠加层关联,而不是单独的查看器。'; + + @override + String get wizardMapOpsFeature1 => '在地图上跟踪你自己的位置、队友位置和移动轨迹。'; + + @override + String get wizardMapOpsFeature2 => '打开消息中的绘图,内联预览,并在需要时将其从地图上移除。'; + + @override + String get wizardMapOpsFeature3 => '使用中继器地图视图和共享叠加层来了解现场网络覆盖范围。'; + + @override + String get wizardToolsTitle => '消息之外的工具'; + + @override + String get wizardToolsDescription => '这里不只有四个主标签页。应用还包含配置、诊断和可选的传感器工作流。'; + + @override + String get wizardToolsFeature1 => '打开设备配置以更改无线电设置、遥测、TX 功率和配套设备详情。'; + + @override + String get wizardToolsFeature2 => '当你需要受监控的传感器面板和快速刷新操作时,启用传感器标签页。'; + + @override + String get wizardToolsFeature3 => '在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。'; + + @override + String get postConnectDiscoveryTitle => '现在发现中继器?'; + + @override + String get postConnectDiscoveryDescription => + '连接后立即运行中继器发现,以查看附近的 MeshCore 节点并更快地将它们添加到您的网络中。'; + + @override + String get contactInSensors => '在传感器中'; + + @override + String get contactAddToSensors => '添加到传感器'; + + @override + String get contactSetPath => '设置路径'; + + @override + String contactAddedToSensors(String contactName) { + return '已将 $contactName 添加到传感器'; + } + + @override + String contactFailedToClearRoute(String error) { + return '清除路由失败:$error'; + } + + @override + String get contactRouteCleared => '路由已清除'; + + @override + String contactRouteSet(String route) { + return '路由已设置:$route'; + } + + @override + String contactFailedToSetRoute(String error) { + return '设置路由失败:$error'; + } + + @override + String get rssi => 'RSSI'; + + @override + String get snr => 'SNR'; + + @override + String get ackTimeout => 'ACK 超时'; + + @override + String get opcode => '操作码'; + + @override + String get payload => '有效载荷'; + + @override + String get hops => '跳数'; + + @override + String get hashSize => '哈希大小'; + + @override + String get pathBytes => '路径字节'; + + @override + String get selectedPath => '选定路径'; + + @override + String get estimatedTx => '预计传输'; + + @override + String get senderToReceipt => '发送到接收'; + + @override + String get receivedCopies => '收到的副本'; + + @override + String get retryCause => '重试原因'; + + @override + String get retryMode => '重试模式'; + + @override + String get retryResult => '重试结果'; + + @override + String get lastRetry => '最后重试'; + + @override + String get rxPackets => 'RX 数据包'; + + @override + String get mesh => 'Mesh'; + + @override + String get rate => '速率'; + + @override + String get window => '窗口'; + + @override + String get posttxDelay => '发送后延迟'; + + @override + String get bandpass => '带通'; + + @override + String get bandpassFilterVoice => '带通语音滤波'; + + @override + String get active => '活动'; + + @override + String get addContact => '添加联系人'; + + @override + String get all => '全部'; + + @override + String get clearAllLabel => '清除全部'; + + @override + String get clearFilters => '清除过滤器'; + + @override + String get clearRoute => '清除路由'; + + @override + String get clearMessages => '清除消息'; + + @override + String get clearScale => '清除比例'; + + @override + String get clearDiscoveries => '清除发现'; + + @override + String get clearOnlineTraceDatabase => '清除在线追踪数据库'; + + @override + String get clearAllChannels => '清除所有频道'; + + @override + String get clearAllContacts => '清除所有联系人'; + + @override + String get clearChannels => '清除频道'; + + @override + String get clearContacts => '清除联系人'; + + @override + String get clearPathOnMaxRetry => '最大重试时清除路径'; + + @override + String get create => '创建'; + + @override + String get custom => '自定义'; + + @override + String get defaultValue => '默认'; + + @override + String get duplicate => '复制'; + + @override + String get editName => '编辑名称'; + + @override + String get open => '打开'; + + @override + String get paste => '粘贴'; + + @override + String get preview => '预览'; + + @override + String get remove => '移除'; + + @override + String get rename => '重命名'; + + @override + String get resolveAll => '解析全部'; + + @override + String get send => '发送'; + + @override + String get sendAnyway => '仍然发送'; + + @override + String get share => '分享'; + + @override + String get trace => '追踪'; + + @override + String get discovery => '发现'; + + @override + String get discoverRepeaters => '发现中继器'; + + @override + String get discoverSensors => '发现传感器'; + + @override + String get repeaterDiscoverySent => '中继器发现已发送'; + + @override + String get sensorDiscoverySent => '传感器发现已发送'; + + @override + String get clearedPendingDiscoveries => '已清除待处理的发现。'; + + @override + String get autoDiscovery => '自动发现'; + + @override + String get enableAutomaticAdding => '启用自动添加'; + + @override + String get autoaddRepeaters => '自动添加中继器'; + + @override + String get autoaddRoomServers => '自动添加房间服务器'; + + @override + String get autoaddSensors => '自动添加传感器'; + + @override + String get autoaddUsers => '自动添加用户'; + + @override + String get overwriteOldestWhenFull => '满时覆盖最旧的'; + + @override + String get storage => '存储'; + + @override + String get dangerZone => '危险区域'; + + @override + String get profiles => '配置文件'; + + @override + String get favourites => '收藏夹'; + + @override + String get sensors => '传感器'; + + @override + String get others => '其他'; + + @override + String get gpsModule => 'GPS 模块'; + + @override + String get liveTraffic => '实时流量'; + + @override + String get repeatersMap => '中继器地图'; + + @override + String get blePacketLogs => 'BLE 数据包日志'; + + @override + String get onlineTraceDatabase => '在线追踪数据库'; + + @override + String get routePathByteSize => '路由路径字节大小'; + + @override + String get messageNotifications => '消息通知'; + + @override + String get sarAlerts => 'SAR 警报'; + + @override + String get discoveryNotifications => '发现通知'; + + @override + String get updateNotifications => '更新通知'; + + @override + String get muteWhileAppIsOpen => '应用打开时静音'; + + @override + String get disableContacts => '禁用联系人'; + + @override + String get enableSensorsTab => '启用传感器标签'; + + @override + String get enableProfiles => '启用配置文件'; + + @override + String get nearestRepeaterFallback => '最近中继器回退'; + + @override + String get deleteAllStoredMessageHistory => '删除所有存储的消息历史'; + + @override + String get messageFontSize => '消息字体大小'; + + @override + String get rotateMapWithHeading => '随航向旋转地图'; + + @override + String get showMapDebugInfo => '显示地图调试信息'; + + @override + String get openMapInFullscreen => '全屏打开地图'; + + @override + String get showSarMarkersLabel => '显示 SAR 标记'; + + @override + String get hideRepeatersOnMap => '在地图上隐藏中继器'; + + @override + String get setMapScale => '设置地图比例'; + + @override + String get customMapScaleSaved => '自定义地图比例已保存'; + + @override + String get voiceBitrate => '语音比特率'; + + @override + String get voiceCompressor => '语音压缩器'; + + @override + String get voiceLimiter => '语音限制器'; + + @override + String get micAutoGain => '麦克风自动增益'; + + @override + String get echoCancellation => '回声消除'; + + @override + String get noiseSuppression => '噪音抑制'; + + @override + String get trimSilenceInVoiceMessages => '修剪语音消息中的静音'; + + @override + String get compressor => '压缩器'; + + @override + String get limiter => '限制器'; + + @override + String get autoGain => '自动增益'; + + @override + String get echoCancel => '回声'; + + @override + String get noiseSuppress => '噪声'; + + @override + String get silenceTrim => '静音'; + + @override + String get maxImageSize => '最大图片大小'; + + @override + String get imageCompression => '图片压缩'; + + @override + String get grayscale => '灰度'; + + @override + String get ultraMode => '超级模式'; + + @override + String get fastPrivateGpsUpdates => '快速私有 GPS 更新'; + + @override + String get movementThreshold => '移动阈值'; + + @override + String get fastGpsMovementThreshold => '快速 GPS 移动阈值'; + + @override + String get fastGpsActiveuseInterval => '快速 GPS 活动使用间隔'; + + @override + String get activeuseUpdateInterval => '活动使用更新间隔'; + + @override + String get repeatNearbyTraffic => '转发附近流量'; + + @override + String get relayThroughRepeatersAcrossTheMesh => '通过网格中的中继器转发'; + + @override + String get nearbyOnlyWithoutRepeaterFlooding => '仅附近,无中继器泛洪'; + + @override + String get multihop => '多跳'; + + @override + String get createProfile => '创建配置文件'; + + @override + String get renameProfile => '重命名配置文件'; + + @override + String get newProfile => '新配置文件'; + + @override + String get manageProfiles => '管理配置文件'; + + @override + String get enableProfilesToStartManagingThem => '启用配置文件以开始管理它们。'; + + @override + String get openMessage => '打开消息'; + + @override + String get jumpToTheRelatedSarMessage => '跳转到相关的 SAR 消息'; + + @override + String get removeSarMarker => '移除 SAR 标记'; + + @override + String get pleaseSelectADestinationToSendSarMarker => '请选择目标以发送 SAR 标记'; + + @override + String get sarMarkerBroadcastToPublicChannel => 'SAR 标记已广播到公共频道'; + + @override + String get sarMarkerSentToRoom => 'SAR 标记已发送到房间'; + + @override + String get loadFromGallery => '从图库加载'; + + @override + String get replaceImage => '替换图片'; + + @override + String get selectFromGallery => '从图库选择'; + + @override + String get team => '团队'; + + @override + String get found => '已找到'; + + @override + String get staging => '集结区'; + + @override + String get object => '物体'; + + @override + String get quiet => '安静'; + + @override + String get busy => '繁忙'; + + @override + String get searchMessages => '搜索消息'; + + @override + String get sendImageFromGallery => '从图库发送图片'; + + @override + String get takePhoto => '拍照'; + + @override + String get allMessages => '所有消息'; + + @override + String get sendToPublicChannel => '发送到公共频道?'; + + @override + String get selectMarkerTypeAndDestination => '选择标记类型和目标'; + + @override + String get noDestinationsAvailableLabel => '没有可用的目标'; + + @override + String get image => '图片'; + + @override + String get format => '格式'; + + @override + String get dimensions => '尺寸'; + + @override + String get segments => '分段'; + + @override + String get transfers => '传输'; + + @override + String get downloadedBy => '已下载'; + + @override + String get saveDiscoverySettings => '保存发现设置'; + + @override + String get savePublicInfo => '保存公共信息'; + + @override + String get saveRadioSettings => '保存无线电设置'; + + @override + String get savePath => '保存路径'; + + @override + String get wipeDeviceData => '擦除设备数据'; + + @override + String get wipeDevice => '擦除设备'; + + @override + String get destructiveDeviceActions => '破坏性设备操作。'; + + @override + String get chooseAPresetOrFinetuneCustomRadioSettings => '选择预设或微调自定义无线电设置。'; + + @override + String get used => '已使用'; + + @override + String get total => '总计'; + + @override + String get renameValue => '重命名值'; + + @override + String get customizeFields => '自定义字段'; + + @override + String get livePreview => '实时预览'; + + @override + String get refreshSchedule => '刷新计划'; + + @override + String get noResponse => '无响应'; + + @override + String get refreshing => '刷新中'; + + @override + String get unavailable => '不可用'; + + @override + String get pickARelayOrNodeToWatchInSensors => '选择要在传感器中监视的中继或节点。'; + + @override + String get publicKeyLabel => '公钥'; + + @override + String get alreadyInContacts => '已在联系人中'; + + @override + String get connectToADeviceBeforeAddingContacts => '添加联系人前请连接到设备'; + + @override + String get fromContacts => '来自联系人'; + + @override + String get onlineOnly => '仅在线'; + + @override + String get inBoth => '两者中'; + + @override + String get source => '来源'; + + @override + String get allMessagesCleared => '所有消息已清除'; + + @override + String get onlineTraceDatabaseCleared => '在线追踪数据库已清除'; + + @override + String get packetLogsCleared => '数据包日志已清除'; + + @override + String get hexDataCopiedToClipboard => '十六进制数据已复制到剪贴板'; + + @override + String get developerModeEnabled => '开发者模式已启用'; + + @override + String get developerModeDisabled => '开发者模式已禁用'; + + @override + String get clipboardIsEmpty => '剪贴板为空'; + + @override + String get contactImported => '联系人已导入'; + + @override + String get contactLinkCopiedToClipboard => '联系人链接已复制到剪贴板'; + + @override + String get failedToExportContact => '导出联系人失败'; + + @override + String get noLogsToExport => '没有日志可导出'; + + @override + String get exportAsCsv => '导出为 CSV'; + + @override + String get exportAsText => '导出为文本'; + + @override + String get receivedRfc3339 => '收到 (RFC3339)'; + + @override + String get buildTime => '构建时间'; + + @override + String get downloadUrlNotAvailable => '下载链接不可用'; + + @override + String get cannotOpenDownloadUrl => '无法打开下载链接'; + + @override + String get updateCheckIsOnlyAvailableOnAndroid => '更新检查仅在 Android 上可用'; + + @override + String get youAreRunningTheLatestVersion => '您正在使用最新版本'; + + @override + String get updateAvailableButDownloadUrlNotFound => '有更新可用但未找到下载链接'; + + @override + String get startTictactoe => '开始井字棋'; + + @override + String get tictactoeUnavailable => '井字棋不可用'; + + @override + String get tictactoeOpponentUnknown => '井字棋:对手未知'; + + @override + String get tictactoeWaitingForStart => '井字棋:等待开始'; + + @override + String get acceptsShareLinks => '接受分享链接'; + + @override + String get supportsRawHex => '支持原始十六进制'; + + @override + String get clipboardfriendly => '剪贴板友好'; + + @override + String get captured => '已捕获'; + + @override + String get size => '大小'; + + @override + String get noCustomChannelsToClear => '没有自定义频道可清除。'; + + @override + String get noDeviceContactsToClear => '没有设备联系人可清除。'; + + @override + String get aToZ => 'A-Z'; + + @override + String get add => '添加'; + + @override + String get addAdditionalDetails => '添加其他详情'; + + @override + String get addPeer => '添加节点'; + + @override + String get addPeerManually => '手动添加节点'; + + @override + String addedToContacts(String name) { + return '$name 已添加到联系人'; + } + + @override + String addedToSensors(String name) { + return '$name 已添加到传感器'; + } + + @override + String get allowAll => '全部允许'; + + @override + String get anonymousRxStats => '匿名接收统计'; + + @override + String get appearance => '外观'; + + @override + String get ascii => 'ASCII'; + + @override + String get baseTelemetry => '基础遥测'; + + @override + String get blePin => 'BLE PIN'; + + @override + String get buzzerAlerts => '蜂鸣器警报'; + + @override + String get cancelImageReceive => '取消接收图片'; + + @override + String get choose => '选择'; + + @override + String get chooseWhichTabsAndContactSectionsToShow => '选择要显示的标签页和联系人分区'; + + @override + String get clearCache => '清除缓存'; + + @override + String get clearHistory => '清除历史'; + + @override + String get clearLiveView => '清除实时视图'; + + @override + String get clearLogs => '清除日志'; + + @override + String get clearOfflineCache => '清除离线缓存?'; + + @override + String get clearOverlay => '清除叠加层'; + + @override + String get clearRouteOnlyAfterAllRetriesFail => '仅在所有重试失败后清除路由'; + + @override + String get codecBitrateAndAudioProcessing => '编解码器、比特率和音频处理'; + + @override + String get connectByIpAddress => '通过 IP 地址连接'; + + @override + String get controlWhichAlertsYouReceive => '控制接收哪些警报'; + + @override + String get copyFullHex => '复制完整 Hex'; + + @override + String get copyRawResponse => '复制原始响应'; + + @override + String get couldNotLoadMetHistory => '无法加载 MET 历史'; + + @override + String customizeSensor(String name) { + return '自定义 $name'; + } + + @override + String get data => '数据'; + + @override + String get deleteGroup => '删除群组'; + + @override + String deleteStyleConfirm(String name) { + return '删除 $name?'; + } + + @override + String deleteThisDrawing(String type) { + return '删除此$type?'; + } + + @override + String get deny => '拒绝'; + + @override + String get deviceInfo => '设备信息'; + + @override + String get deviceSettings => '设备设置'; + + @override + String get deviceTimeSynced => '设备时间已同步到此手机。'; + + @override + String get disableFastGpsPublishing => '禁用快速 GPS 发布'; + + @override + String get displayMarkersAndTraceDatabase => '显示、标记和轨迹数据库'; + + @override + String get download => '下载'; + + @override + String get environmentalTelemetry => '环境遥测'; + + @override + String errorCheckingForUpdates(String error) { + return '检查更新出错:$error'; + } + + @override + String errorGeneric(String error) { + return '错误:$error'; + } + + @override + String errorOpeningDownload(String error) { + return '打开下载出错:$error'; + } + + @override + String get exportLogs => '导出日志'; + + @override + String get extraAggressiveAvifCompression => '超强 AVIF 压缩'; + + @override + String failedToAddContact(String error) { + return '添加联系人失败:$error'; + } + + @override + String failedToAddName(String name, String error) { + return '添加 $name 失败:$error'; + } + + @override + String failedToClearChannels(String error) { + return '清除频道失败:$error'; + } + + @override + String failedToClearContacts(String error) { + return '清除联系人失败:$error'; + } + + @override + String get failedToConnectViaSerial => '通过串口连接失败'; + + @override + String failedToLoadPreviewImage(String error) { + return '加载预览图片失败:$error'; + } + + @override + String failedToLoadTrace(String error) { + return '加载轨迹失败:$error'; + } + + @override + String failedToSendSarMarker(String error) { + return '发送 SAR 标记失败:$error'; + } + + @override + String failedToSetBuzzerMode(String error) { + return '设置蜂鸣器模式失败:$error'; + } + + @override + String failedToSetGpsMode(String error) { + return '设置 GPS 模式失败:$error'; + } + + @override + String failedToSyncDeviceTime(String error) { + return '同步设备时间失败:$error'; + } + + @override + String failedToWipeDeviceData(String error) { + return '擦除设备数据失败:$error'; + } + + @override + String get fastGpsTargetChannel => '快速 GPS 目标频道'; + + @override + String get fastUpdatesThresholdsAndPermissions => '快速更新、阈值和权限'; + + @override + String get fetchingCatalog => '正在获取目录…'; + + @override + String get fifteenMinutes => '15 分钟'; + + @override + String get fifteenSeconds => '15 秒'; + + @override + String get filterByDirection => '按方向筛选'; + + @override + String get finish => '完成'; + + @override + String get fiveMinutes => '5 分钟'; + + @override + String get fiveSeconds => '5 秒'; + + @override + String get fix => '定位'; + + @override + String get gps => 'GPS'; + + @override + String get gpsAdvertPolicy => 'GPS 广播策略'; + + @override + String get gpsAndLocation => 'GPS 与位置'; + + @override + String get gpsDiagnostics => 'GPS 诊断'; + + @override + String get gpsSection => 'GPS'; + + @override + String get help => '帮助'; + + @override + String get hex => 'Hex'; + + @override + String get hidden => '已隐藏'; + + @override + String get howTheRadioAutoAddsDiscoveredNodes => '无线电自动添加已发现节点的方式。'; + + @override + String get images => '图片'; + + @override + String get importProfile => '导入配置文件'; + + @override + String get lastFix => '最后定位'; + + @override + String get latest => '最新'; + + @override + String get latitude => '纬度'; + + @override + String get loadASavedRegion => '加载已保存的区域'; + + @override + String get loading => '加载中'; + + @override + String get locationSharingHardwareAndUpdateInterval => '位置共享、硬件和更新间隔。'; + + @override + String get locationTelemetry => '位置遥测'; + + @override + String get lockDestination => '锁定目的地'; + + @override + String get longitude => '经度'; + + @override + String get mapStyle => '地图样式'; + + @override + String get max => '最大'; + + @override + String get maxZoom => '最大缩放'; + + @override + String get messaging => '消息传递'; + + @override + String get metHistory => 'MET 历史'; + + @override + String get min => '最小'; + + @override + String get minZoom => '最小缩放'; + + @override + String get moveDown => '下移'; + + @override + String get moveUp => '上移'; + + @override + String get multiAckMode => '多重 ACK 模式'; + + @override + String get multiDeviceWorkspaceManagement => '多设备工作区管理'; + + @override + String get myLocation => '我的位置'; + + @override + String get nameAndTelemetryShared => '名称和遥测数据已与其他设备共享。'; + + @override + String newVersionAvailable(String version) { + return '新版本:$version'; + } + + @override + String get newer => '较新'; + + @override + String get noNeighboursFound => '未找到邻近节点'; + + @override + String get noServersFound => '未找到服务器'; + + @override + String get notSet => '未设置'; + + @override + String get notifications => '通知'; + + @override + String get offZeroSeconds => '关闭 (0 秒)'; + + @override + String get offlineMaps => '离线地图'; + + @override + String get older => '较旧'; + + @override + String get oneByteMode0 => '1 字节(模式 0)'; + + @override + String get oneHour => '1 小时'; + + @override + String get oneMinute => '1 分钟'; + + @override + String get openPacketLogs => '打开数据包日志'; + + @override + String get packetTypeHelp => '数据包类型帮助'; + + @override + String get pasteShareLinkOrHexAdvert => '粘贴分享链接或 Hex 广播'; + + @override + String get pathHash => '路径哈希'; + + @override + String get pathSize => '路径大小'; + + @override + String get ping => 'Ping'; + + @override + String get polygon => '多边形'; + + @override + String get rawResponseCopied => '原始响应已复制'; + + @override + String get resendThroughNearestRepeaterOnFailure => '失败时通过最近的中继器重新发送'; + + @override + String get resolveContact => '解析联系人'; + + @override + String get routingRetriesAndDestinationLock => '路由、重试和目的地锁定'; + + @override + String get samples => '采样'; + + @override + String get satellites => '卫星'; + + @override + String get saving => '保存中…'; + + @override + String get search => '搜索'; + + @override + String get searchByNameOrToken => '按名称或令牌搜索'; + + @override + String get searchDiscoveredNodes => '搜索已发现的节点'; + + @override + String get searchInCurrentFilter => '在当前筛选中搜索'; + + @override + String get searchLogs => '搜索日志…'; + + @override + String get searchSensors => '搜索传感器'; + + @override + String get searchThisSection => '搜索此分区'; + + @override + String get selectedRelays => '已选中继器'; + + @override + String get sensor => '传感器'; + + @override + String serialError(String error) { + return '串口错误:$error'; + } + + @override + String setPathForContact(String name) { + return '为 $name 设置路径'; + } + + @override + String get shareLiveGps => '共享实时 GPS'; + + @override + String get shareMyTiles => '共享我的地图瓦片'; + + @override + String get sharingLocationFromPhone => '正在从手机共享此频道的位置。'; + + @override + String get sharingLocationFromRadio => '正在从无线电共享此频道的位置。'; + + @override + String signalDbm(String rssi) { + return '信号 $rssi dBm'; + } + + @override + String get sizeCompressionAndPreview => '大小、压缩和预览'; + + @override + String get simpleMode => '简单模式'; + + @override + String get simpleModeDescription => '仅与收藏联系人聊天和查看地图'; + + @override + String get simpleModeRequiresLockedDestination => + '请先启用“锁定目的地”并选择一个频道,再使用简单模式'; + + @override + String get noFavouriteContacts => '暂无收藏联系人'; + + @override + String get skip => '跳过'; + + @override + String get smallerFileSize => '更小的文件大小'; + + @override + String get sort => '排序'; + + @override + String get stoppedSharingLocation => '已停止在此频道上共享位置。'; + + @override + String get suppressNotificationsWhileInForeground => '前台运行时抑制通知'; + + @override + String get tabsAndNavigation => '标签页与导航'; + + @override + String get templatesAndTutorials => '模板和教程'; + + @override + String get tenMinutes => '10 分钟'; + + @override + String get tenSeconds => '10 秒'; + + @override + String get testSendUpdate => '测试发送更新'; + + @override + String get themeLanguageAndDisplayPreferences => '主题、语言和显示偏好'; + + @override + String get thirtyMinutes => '30 分钟'; + + @override + String get thirtySeconds => '30 秒'; + + @override + String get threeBytesMode2 => '3 字节(模式 2)'; + + @override + String get trafficStatsMessageHistoryAndDeveloperTools => '流量统计、消息历史和开发者工具'; + + @override + String get twoBytesMode1 => '2 字节(模式 1)'; + + @override + String get twoMinutes => '2 分钟'; + + @override + String get undo => '撤销'; + + @override + String get updated => '已更新'; + + @override + String get useContactFlags => '使用联系人标志'; + + @override + String get useSavedCoordinates => '使用已保存的坐标'; + + @override + String get view => '查看'; + + @override + String get viewNeighbours => '查看邻近节点'; + + @override + String get viewPublicStats => '查看公开统计'; + + @override + String get setRegionScope => '设置区域范围'; + + @override + String get regionScope => '区域范围'; + + @override + String get regionScopeNone => '无(全局)'; + + @override + String get clearRegionScope => '清除范围'; + + @override + String get regionScopeWarning => '只有允许此区域的中继器会转发。'; + + @override + String get discoverRegions => '从中继器发现'; + + @override + String get discoveringRegions => '正在发现区域...'; + + @override + String get enterRegionName => '区域名称(例如 auckland)'; + + @override + String get noRegionsFound => '此中继器上未找到区域。'; + + @override + String regionScopeSet(String name) { + return '区域范围已设置为 $name'; + } + + @override + String get regionScopeCleared => '区域范围已清除'; + + @override + String get selectDestinationFirst => '请先选择频道、联系人或房间'; + + @override + String get recipientNoLongerAvailable => '收件人已不可用'; + + @override + String failedToSendMessage(String error) { + return '发送失败:$error'; + } + + @override + String get tictactoeDirectOnly => '井字棋仅适用于私聊消息。请先选择联系人。'; + + @override + String get deviceKeyUnavailable => '设备密钥不可用'; + + @override + String get imageCompressionFailed => '图片压缩失败'; + + @override + String get imageFragmentationFailed => '图片分片失败'; + + @override + String get failedToAnnounceImage => '图片通告失败'; + + @override + String get noRecipientSelected => '未选择收件人'; + + @override + String get imageSendFailed => '图片发送失败'; + + @override + String get microphonePermissionRequiredForVoice => '语音消息需要麦克风权限'; + + @override + String get recordingDiscarded => '已丢弃录音'; + + @override + String publicChannelMediaWarning(String mediaType) { + return '您即将向公共频道发送$mediaType。不建议这样做,因为 mesh 网络中的所有人都可能收到。除非确有需要,请选择私有或带标签的频道。'; + } + + @override + String get selectPrivateChannelToShareLocation => '请选择私有频道来分享您的位置。'; + + @override + String get stopSharingMyLocation => '停止分享我的位置'; + + @override + String get shareMyLocation => '分享我的位置'; + + @override + String get stopRecording => '停止录音'; + + @override + String get recordVoice => '录制语音'; + + @override + String get moreActions => '更多操作'; + + @override + String get noMessagesInFilter => '此筛选条件下没有消息'; + + @override + String get noMatchesInFilter => '此筛选条件下没有匹配项'; + + @override + String syncFailed(String error) { + return '同步失败:$error'; + } + + @override + String get invalidLink => '无效链接'; + + @override + String get cannotOpenLink => '无法打开链接'; + + @override + String get failedToOpenLink => '打开链接失败'; + + @override + String get failedToResendMessage => '重新发送消息失败'; + + @override + String retryFailed(String error) { + return '重试失败:$error'; + } + + @override + String get deleteDrawingAndMessage => '删除绘图和消息'; + + @override + String get lastEchoRelay => '最后回声:中继'; + + @override + String get lastEchoPath => '最后回声:路径'; + + @override + String get lastEchoBytesReport => '最后回声:字节报告'; + + @override + String get noGpsCoordinatesAvailable => '没有可用的 GPS 坐标'; + + @override + String get notASarMarker => '不是搜救标记'; + + @override + String failedToSaveTemplate(String error) { + return '保存模板失败:$error'; + } + + @override + String get drawingNotFound => '未找到绘图'; + + @override + String get unknownDrawingType => '未知绘图类型'; + + @override + String get customMapMarker => '自定义地图标记'; + + @override + String customMapPointLabel(String point) { + return '坐标点:$point'; + } + + @override + String mapIdLabel(String id) { + return '地图 ID:$id'; + } + + @override + String get cannotFetchVoice => '无法获取语音'; + + @override + String get senderContactUnknown => '发送者联系人未知。请先同步联系人。'; + + @override + String get senderRouteUnknown => '发送者路由未知。请先同步联系人/路径。'; + + @override + String messageTooFar(String hops, String maxHops) { + return '消息距离过远($hops 跳,最多 $maxHops 跳)。'; + } + + @override + String get senderRouteNoPathResponse => '发送者路由未响应路径检测。请同步联系人/路径后重试。'; + + @override + String get senderRouteNoRawResponse => '发送者路由在原始传输路径上未响应。'; + + @override + String voiceFetchOverHops(String hops) { + return '通过 $hops 跳获取语音可能需要一些时间。'; + } + + @override + String get voiceReceiveCanceled => '已取消接收语音'; + + @override + String get fetchingMissingVoiceFragments => '正在获取缺失的语音分片'; + + @override + String get receivingVoice => '正在接收语音'; + + @override + String get cannotFetchImage => '无法获取图片'; + + @override + String imageFetchOverHops(String hops) { + return '通过 $hops 跳获取图片可能需要一些时间。'; + } + + @override + String get imageAlreadyBeingReceived => '图片正在接收中'; + + @override + String get loadImage => '加载图片'; + + @override + String get imageFetchFailedToSendRequest => '图片获取请求发送失败'; + + @override + String get imageUnavailable => '图片暂时不可用'; + + @override + String get imageFetchTimedOut => '图片获取超时'; + + @override + String get imageReceiveCanceled => '已取消接收图片'; + + @override + String get closeImagePreview => '关闭图片预览'; + + @override + String get recordingReleaseToSend => '正在录音……松开发送'; + + @override + String get sendingVoice => '正在发送语音……'; + + @override + String get sendLongPressToRecordVoice => '发送(长按录制语音)'; + + @override + String get discardRecording => '丢弃录音'; + + @override + String hopCount(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 跳', + ); + return '$_temp0'; + } + + @override + String get channelsSectionDescription => '用于附近 mesh 通信的广播频道'; + + @override + String get roomsSectionDescription => '用于团队持续协调的共享空间'; + + @override + String get contactsSectionDescription => '可直接联系的人员和设备'; + + @override + String get noRecentChatters => '最近没有人发言'; + + @override + String get public => '公共'; + + @override + String get failedToSendTicTacToeMove => '井字棋走子发送失败'; + + @override + String get youWon => '您赢了'; + + @override + String get opponentWon => '对手赢了'; + + @override + String get gameDraw => '平局'; + + @override + String get yourTurn => '轮到您'; + + @override + String get opponentTurn => '轮到对手'; + + @override + String get pleaseEnterValidCoordinates => '请输入有效坐标'; + + @override + String get locationNotAvailable => '位置不可用'; + + @override + String get customCaveMapPoint => '自定义洞穴地图点'; + + @override + String get mapPoint => '地图点'; + + @override + String get fetchingMissingImageFragments => '正在获取缺失分片……'; + + @override + String get loadingImage => '加载中……'; + + @override + String get receivingImage => '接收中……'; + + @override + String get tapToLoad => '点按加载'; + + @override + String get connectDevice => '连接设备'; + + @override + String get chooseTransportSubtitle => '选择 Bluetooth、WiFi 或串口传输'; + + @override + String get ble => 'BLE'; + + @override + String get network => '网络'; + + @override + String get serial => '串口'; + + @override + String get scan => '扫描'; + + @override + String get pressScanToSearchForDevices => '点按扫描以搜索附近设备'; + + @override + String failedToConnectToDevice(String name) { + return '连接 $name 失败'; + } + + @override + String get unknownDevice => '未知设备'; + + @override + String get showingCachedResultsTapRefresh => '正在显示缓存结果。点按刷新以重新扫描。'; + + @override + String scanningLocalNetworkOnPort(int port) { + return '正在扫描本地网络中端口 $port 上的 MeshCore WiFi 设备'; + } + + @override + String get cancelScanAndAddServer => '取消扫描并添加服务器'; + + @override + String get addServer => '添加服务器'; + + @override + String scanningProgressIps(int scanned, String total) { + return '正在扫描……$scanned/$total 个 IP'; + } + + @override + String get cancelAndEnterManually => '取消并手动输入'; + + @override + String get noRecentOrDiscoveredServers => '暂无最近使用或发现的服务器'; + + @override + String get recentlyUsed => '最近使用'; + + @override + String get connecting => '正在连接……'; + + @override + String get discoveredOnThisNetwork => '在此网络中发现'; + + @override + String serverNoLongerAvailable(String host, int port) { + return '$host:$port 上的服务器已不可用。请重新扫描以查找活跃的服务器。'; + } + + @override + String failedToConnectToHost(String host, int port) { + return '连接 $host:$port 失败'; + } + + @override + String get enterValidIpAddress => '请输入有效的 IP 地址'; + + @override + String get enterValidTcpPort => '请输入有效的 TCP 端口'; + + @override + String get ipAddress => 'IP 地址'; + + @override + String get tcpPort => 'TCP 端口'; + + @override + String get customServerPort => '自定义服务器端口'; + + @override + String get chat => '聊天'; + + @override + String get restoringPreviousLink => '正在恢复先前的连接'; + + @override + String get noDeviceConnected => '未连接设备'; + + @override + String get sendAdvert => '发送我的联系人信息'; + + @override + String get advertMode => '发送我的联系人信息'; + + @override + String get advertModeSubtitle => '选择您的联系人信息应传播多远。'; + + @override + String get floodAdvertSent => '联系人信息已发送至整个 mesh 网络'; + + @override + String get directAdvertSent => '联系人信息已发送至附近设备'; +} diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb new file mode 100644 index 0000000..7f33197 --- /dev/null +++ b/lib/l10n/app_pl.arb @@ -0,0 +1,2617 @@ +{ + "@@locale": "pl", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "The application title" + }, + "messages": "Wiadomości", + "@messages": { + "description": "Messages tab label" + }, + "contacts": "Kontakty", + "@contacts": { + "description": "Contacts tab label" + }, + "map": "Mapa", + "@map": { + "description": "Map tab label" + }, + "settings": "Ustawienia", + "@settings": { + "description": "Settings screen title" + }, + "connect": "Połącz", + "@connect": { + "description": "Connect button label" + }, + "disconnect": "Rozłącz", + "@disconnect": { + "description": "Disconnect button label" + }, + "noDevicesFound": "Nie znaleziono urządzeń", + "@noDevicesFound": { + "description": "Text shown when no BLE devices are found" + }, + "scanAgain": "Skanuj ponownie", + "@scanAgain": { + "description": "Button to restart BLE scanning" + }, + "deviceNotConnected": "Urządzenie nie jest połączone", + "@deviceNotConnected": { + "description": "Error message when device is not connected" + }, + "locationPermissionDenied": "Odmówiono dostępu do lokalizacji", + "@locationPermissionDenied": { + "description": "Error when location permission is denied" + }, + "locationPermissionPermanentlyDenied": "Dostęp do lokalizacji został trwale zablokowany. Włącz go w Ustawieniach.", + "@locationPermissionPermanentlyDenied": { + "description": "Error when location permission is permanently denied" + }, + "locationPermissionRequired": "Dostęp do lokalizacji jest wymagany do śledzenia GPS i koordynacji zespołu. Możesz go włączyć później w Ustawieniach.", + "@locationPermissionRequired": { + "description": "Message when location permission is needed" + }, + "locationServicesDisabled": "Usługi lokalizacji są wyłączone. Włącz je w Ustawieniach.", + "@locationServicesDisabled": { + "description": "Error when location services are disabled" + }, + "failedToGetGpsLocation": "Nie udało się pobrać lokalizacji GPS", + "@failedToGetGpsLocation": { + "description": "Error when GPS location cannot be obtained" + }, + "failedToAdvertise": "Nie udało się rozgłosić: {error}", + "@failedToAdvertise": { + "description": "Error message for failed advertisement", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "Anuluj ponowne łączenie", + "@cancelReconnection": { + "description": "Tooltip for cancel reconnection button" + }, + "general": "Ogólne", + "@general": { + "description": "General settings section header" + }, + "theme": "Motyw", + "@theme": { + "description": "Theme setting label" + }, + "chooseTheme": "Wybierz motyw", + "@chooseTheme": { + "description": "Theme selection dialog title" + }, + "light": "Jasny", + "@light": { + "description": "Light theme option" + }, + "dark": "Ciemny", + "@dark": { + "description": "Dark theme option" + }, + "blueLightTheme": "Niebieski jasny motyw", + "@blueLightTheme": { + "description": "Description for blue light theme" + }, + "blueDarkTheme": "Niebieski ciemny motyw", + "@blueDarkTheme": { + "description": "Description for blue dark theme" + }, + "sarRed": "SAR Czerwony", + "@sarRed": { + "description": "SAR Red theme option" + }, + "alertEmergencyMode": "Tryb alarmowy/awaryjny", + "@alertEmergencyMode": { + "description": "Description for SAR Red theme" + }, + "sarGreen": "SAR Zielony", + "@sarGreen": { + "description": "SAR Green theme option" + }, + "safeAllClearMode": "Tryb bezpieczny/wszystko jasne", + "@safeAllClearMode": { + "description": "Description for SAR Green theme" + }, + "autoSystem": "Automatyczny (system)", + "@autoSystem": { + "description": "Auto/System theme option" + }, + "followSystemTheme": "Dopasuj do motywu systemowego", + "@followSystemTheme": { + "description": "Description for system theme" + }, + "showRxTxIndicators": "Pokaż wskaźniki RX/TX", + "@showRxTxIndicators": { + "description": "Setting to show RX/TX indicators" + }, + "disableMap": "Wyłącz mapę", + "@disableMap": { + "description": "Setting to disable the map tab" + }, + "language": "Język", + "@language": { + "description": "Language setting label" + }, + "chooseLanguage": "Wybierz język", + "@chooseLanguage": { + "description": "Language selection dialog title" + }, + "save": "Zapisz", + "@save": { + "description": "Save button label" + }, + "cancel": "Anuluj", + "@cancel": { + "description": "Cancel button label" + }, + "close": "Zamknij", + "@close": { + "description": "Close button label" + }, + "about": "Informacje", + "@about": { + "description": "About section header" + }, + "appVersion": "Wersja aplikacji", + "@appVersion": { + "description": "App version label" + }, + "aboutMeshCoreSar": "O MeshCore SAR", + "@aboutMeshCoreSar": { + "description": "About dialog title" + }, + "aboutDescription": "Aplikacja poszukiwawczo-ratownicza zaprojektowana dla zespołów reagowania kryzysowego. Funkcje obejmują:\n\n• Sieć BLE mesh do komunikacji urządzenie-urządzenie\n• Mapy offline z wieloma warstwami\n• Śledzenie członków zespołu w czasie rzeczywistym\n• Taktyczne znaczniki SAR (odnaleziona osoba, pożar, strefa zbiórki)\n• Zarządzanie kontaktami i wiadomościami\n• Śledzenie GPS z kompasem\n• Buforowanie kafelków mapy do użytku offline", + "@aboutDescription": { + "description": "About dialog description" + }, + "technologiesUsed": "Użyte technologie:", + "@technologiesUsed": { + "description": "Technologies used section title" + }, + "technologiesList": "• Flutter do rozwoju wieloplatformowego\n• BLE (Bluetooth Low Energy) do sieci mesh\n• OpenStreetMap do map\n• Provider do zarządzania stanem\n• SharedPreferences do pamięci lokalnej", + "@technologiesList": { + "description": "List of technologies used" + }, + "moreInfo": "Więcej informacji", + "@moreInfo": { + "description": "More info button label" + }, + "packageName": "Nazwa pakietu", + "@packageName": { + "description": "Package name label" + }, + "sampleData": "Dane przykładowe", + "@sampleData": { + "description": "Sample data section header" + }, + "sampleDataDescription": "Wczytaj lub wyczyść przykładowe kontakty, wiadomości kanałów i znaczniki SAR do testów", + "@sampleDataDescription": { + "description": "Sample data section description" + }, + "loadSampleData": "Wczytaj dane przykładowe", + "@loadSampleData": { + "description": "Load sample data button" + }, + "clearAllData": "Wyczyść wszystkie dane", + "@clearAllData": { + "description": "Clear all data button" + }, + "clearAllDataConfirmTitle": "Wyczyść wszystkie dane", + "@clearAllDataConfirmTitle": { + "description": "Clear data confirmation dialog title" + }, + "clearAllDataConfirmMessage": "To wyczyści wszystkie kontakty i znaczniki SAR. Czy na pewno?", + "@clearAllDataConfirmMessage": { + "description": "Clear data confirmation message" + }, + "clear": "Wyczyść", + "@clear": { + "description": "Clear button label" + }, + "loadedSampleData": "Wczytano {teamCount} członków zespołu, {channelCount} kanałów, {sarCount} znaczników SAR, {messageCount} wiadomości", + "@loadedSampleData": { + "description": "Success message after loading sample data", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "Nie udało się wczytać danych przykładowych: {error}", + "@failedToLoadSampleData": { + "description": "Error message when sample data fails to load", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "Wszystkie dane wyczyszczono", + "@allDataCleared": { + "description": "Success message after clearing all data" + }, + "failedToStartBackgroundTracking": "Nie udało się uruchomić śledzenia w tle. Sprawdź uprawnienia i połączenie BLE.", + "@failedToStartBackgroundTracking": { + "description": "Error message when background tracking fails to start" + }, + "locationBroadcast": "Nadawanie lokalizacji: {latitude}, {longitude}", + "@locationBroadcast": { + "description": "Success message for location broadcast", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "Domyślny PIN dla urządzeń bez ekranu to 123456. Problem z parowaniem? Zapomnij urządzenie Bluetooth w ustawieniach systemu.", + "@defaultPinInfo": { + "description": "Information about default PIN for pairing" + }, + "noMessagesYet": "Brak wiadomości", + "@noMessagesYet": { + "description": "Empty state message when there are no messages" + }, + "pullDownToSync": "Przeciągnij w dół, aby zsynchronizować wiadomości", + "@pullDownToSync": { + "description": "Instruction to pull down to refresh messages" + }, + "deleteContact": "Usuń kontakt", + "@deleteContact": { + "description": "Delete contact action label" + }, + "delete": "Usuń", + "@delete": { + "description": "Delete button label" + }, + "viewOnMap": "Pokaż na mapie", + "@viewOnMap": { + "description": "Action to view contact location on map" + }, + "refresh": "Odśwież", + "@refresh": { + "description": "Refresh button label" + }, + "resetPath": "Resetuj trasę (wyznacz ponownie)", + "@resetPath": { + "description": "Action to reset contact path for re-routing" + }, + "copiedToClipboard": "Skopiowano {label} do schowka", + "@copiedToClipboard": { + "description": "Success message when a value is copied to clipboard", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "Wprowadź hasło", + "@pleaseEnterPassword": { + "description": "Validation message for empty password field" + }, + "failedToSyncContacts": "Nie udało się zsynchronizować kontaktów: {error}", + "@failedToSyncContacts": { + "description": "Error message when contact sync fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "Zalogowano pomyślnie! Oczekiwanie na wiadomości z pokoju...", + "@loggedInSuccessfully": { + "description": "Success message after successful room login" + }, + "loginFailed": "Logowanie nie powiodło się - nieprawidłowe hasło", + "@loginFailed": { + "description": "Error message when room login fails" + }, + "loggingIn": "Logowanie do {roomName}...", + "@loggingIn": { + "description": "Status message during room login process", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "Nie udało się wysłać logowania: {error}", + "@failedToSendLogin": { + "description": "Error message when login command fails to send", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "Niska dokładność lokalizacji", + "@lowLocationAccuracy": { + "description": "Warning title for low GPS accuracy" + }, + "continue_": "Kontynuuj", + "@continue_": { + "description": "Continue button label" + }, + "sendSarMarker": "Wyślij znacznik SAR", + "@sendSarMarker": { + "description": "Action to send SAR marker" + }, + "deleteDrawing": "Usuń rysunek", + "@deleteDrawing": { + "description": "Action to delete a map drawing" + }, + "drawingTools": "Narzędzia rysowania", + "@drawingTools": { + "description": "Drawing tools section or menu title" + }, + "drawLine": "Rysuj linię", + "@drawLine": { + "description": "Map drawing mode: line" + }, + "drawLineDesc": "Narysuj odręczną linię na mapie", + "@drawLineDesc": { + "description": "Description for line drawing mode" + }, + "drawRectangle": "Rysuj prostokąt", + "@drawRectangle": { + "description": "Map drawing mode: rectangle" + }, + "drawRectangleDesc": "Narysuj prostokątny obszar na mapie", + "@drawRectangleDesc": { + "description": "Description for rectangle drawing mode" + }, + "measureDistance": "Mierz odległość", + "@measureDistance": { + "description": "Map drawing mode: measure distance" + }, + "measureDistanceDesc": "Przytrzymaj dwa punkty, aby zmierzyć", + "@measureDistanceDesc": { + "description": "Description for distance measurement mode" + }, + "clearMeasurement": "Wyczyść pomiar", + "@clearMeasurement": { + "description": "Tooltip to clear measurement" + }, + "distanceLabel": "Odległość: {distance}", + "@distanceLabel": { + "description": "Label showing measured distance", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "Przytrzymaj dla drugiego punktu", + "@longPressForSecondPoint": { + "description": "Instruction when first measurement point is set" + }, + "longPressToStartMeasurement": "Przytrzymaj, aby ustawić pierwszy punkt", + "@longPressToStartMeasurement": { + "description": "Instruction to start measurement" + }, + "longPressToStartNewMeasurement": "Przytrzymaj, aby rozpocząć nowy pomiar", + "@longPressToStartNewMeasurement": { + "description": "Instruction to restart measurement after completion" + }, + "shareDrawings": "Udostępnij rysunki", + "@shareDrawings": { + "description": "Action to share drawings to network" + }, + "clearAllDrawings": "Wyczyść wszystkie rysunki", + "@clearAllDrawings": { + "description": "Action to clear all local drawings" + }, + "completeLine": "Zakończ linię", + "@completeLine": { + "description": "Tooltip to complete drawing a line" + }, + "broadcastDrawingsToTeam": "Nadaj {count} rysunek{plural} do zespołu", + "@broadcastDrawingsToTeam": { + "description": "Subtitle showing how many drawings will be broadcast", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "Usuń wszystkie {count} rysunek{plural}", + "@removeAllDrawings": { + "description": "Subtitle for remove all drawings action", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "Usunąć wszystkie {count} rysunek{plural} z mapy?", + "@deleteAllDrawingsConfirm": { + "description": "Confirmation dialog message for deleting all drawings", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "Rysunek", + "@drawing": { + "description": "Generic drawing label" + }, + "shareDrawingsCount": "Udostępnij {count} rysunek{plural}", + "@shareDrawingsCount": { + "description": "Title for share drawings dialog", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "Pokaż odebrane rysunki", + "@showReceivedDrawings": { + "description": "Toggle to show/hide received drawings from other team members" + }, + "showingAllDrawings": "Pokazywane są wszystkie rysunki", + "@showingAllDrawings": { + "description": "Subtitle when received drawings are visible" + }, + "showingOnlyYourDrawings": "Pokazywane są tylko twoje rysunki", + "@showingOnlyYourDrawings": { + "description": "Subtitle when received drawings are hidden" + }, + "showSarMarkers": "Pokaż znaczniki SAR", + "@showSarMarkers": { + "description": "Toggle to show/hide SAR markers on map" + }, + "showingSarMarkers": "Pokazywane są znaczniki SAR", + "@showingSarMarkers": { + "description": "Subtitle when SAR markers are visible" + }, + "hidingSarMarkers": "Ukrywanie znaczników SAR", + "@hidingSarMarkers": { + "description": "Subtitle when SAR markers are hidden" + }, + "clearAll": "Wyczyść wszystko", + "@clearAll": { + "description": "Clear all button label" + }, + "publicChannel": "Kanał publiczny", + "@publicChannel": { + "description": "Public channel option for sharing" + }, + "broadcastToAll": "Nadaj do wszystkich pobliskich węzłów (tymczasowo)", + "@broadcastToAll": { + "description": "Description for public channel broadcast" + }, + "storedPermanently": "Przechowywane na stałe w pokoju", + "@storedPermanently": { + "description": "Description for room storage permanence" + }, + "notConnectedToDevice": "Brak połączenia z urządzeniem", + "@notConnectedToDevice": { + "description": "Error message when device is not connected for direct messaging" + }, + "typeYourMessage": "Wpisz wiadomość...", + "@typeYourMessage": { + "description": "Placeholder text for message input field" + }, + "quickLocationMarker": "Szybki znacznik lokalizacji", + "@quickLocationMarker": { + "description": "Subtitle for SAR marker sheet header" + }, + "markerType": "Typ znacznika", + "@markerType": { + "description": "Label for marker type selection section" + }, + "sendTo": "Wyślij do", + "@sendTo": { + "description": "Label for destination selection section" + }, + "noDestinationsAvailable": "Brak dostępnych odbiorców.", + "@noDestinationsAvailable": { + "description": "Warning when no rooms or channels exist" + }, + "selectDestination": "Wybierz odbiorcę...", + "@selectDestination": { + "description": "Placeholder for destination dropdown" + }, + "ephemeralBroadcastInfo": "Tymczasowe: nadawane tylko drogą radiową. Nie jest zapisywane - węzły muszą być online.", + "@ephemeralBroadcastInfo": { + "description": "Information about ephemeral channel broadcasts" + }, + "persistentRoomInfo": "Trwałe: zapisywane niezmiennie w pokoju. Synchronizowane automatycznie i zachowywane offline.", + "@persistentRoomInfo": { + "description": "Information about persistent room storage" + }, + "location": "Lokalizacja", + "@location": { + "description": "Label for location section" + }, + "fromMap": "Z mapy", + "@fromMap": { + "description": "Badge showing location is from map tap" + }, + "gettingLocation": "Pobieranie lokalizacji...", + "@gettingLocation": { + "description": "Loading message while fetching GPS location" + }, + "locationError": "Błąd lokalizacji", + "@locationError": { + "description": "Title for location error messages" + }, + "retry": "Ponów", + "@retry": { + "description": "Retry button label" + }, + "refreshLocation": "Odśwież lokalizację", + "@refreshLocation": { + "description": "Tooltip for refresh location button" + }, + "accuracyMeters": "Dokładność: ±{accuracy}m", + "@accuracyMeters": { + "description": "Display of GPS accuracy in meters", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "Notatki (opcjonalnie)", + "@notesOptional": { + "description": "Label for optional notes field" + }, + "addAdditionalInformation": "Dodaj dodatkowe informacje...", + "@addAdditionalInformation": { + "description": "Placeholder for notes field" + }, + "lowAccuracyWarning": "Dokładność lokalizacji wynosi ±{accuracy}m. To może nie być wystarczająco dokładne dla działań SAR.\n\nKontynuować mimo to?", + "@lowAccuracyWarning": { + "description": "Warning dialog content for low GPS accuracy", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "Zaloguj do pokoju", + "@loginToRoom": { + "description": "Title for room login dialog" + }, + "enterPasswordInfo": "Wprowadź hasło, aby uzyskać dostęp do tego pokoju. Hasło zostanie zapisane na przyszłość.", + "@enterPasswordInfo": { + "description": "Information about room password" + }, + "password": "Hasło", + "@password": { + "description": "Password field label" + }, + "enterRoomPassword": "Wpisz hasło pokoju", + "@enterRoomPassword": { + "description": "Password field hint" + }, + "loggingInDots": "Logowanie...", + "@loggingInDots": { + "description": "Button text while logging in" + }, + "login": "Zaloguj", + "@login": { + "description": "Login button label" + }, + "failedToAddRoom": "Nie udało się dodać pokoju do urządzenia: {error}\n\nPokój mógł jeszcze nie rozpocząć nadawania.\nSpróbuj poczekać, aż pokój zacznie nadawać.", + "@failedToAddRoom": { + "description": "Error message when adding room fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "Bezpośrednie", + "@direct": { + "description": "Direct routing indicator" + }, + "flood": "Rozgłoszeniowo", + "@flood": { + "description": "Flood routing indicator" + }, + "loggedIn": "Zalogowano", + "@loggedIn": { + "description": "Logged in status badge" + }, + "noGpsData": "Brak danych GPS", + "@noGpsData": { + "description": "Message when GPS data is not available" + }, + "distance": "Odległość", + "@distance": { + "description": "Distance label" + }, + "directPingTimeout": "Przekroczono czas bezpośredniego ping - ponawianie dla {name} przez flooding...", + "@directPingTimeout": { + "description": "Warning when direct ping times out", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "Ping do {name} nie powiódł się - nie otrzymano odpowiedzi", + "@pingFailed": { + "description": "Error message when ping fails", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "Czy na pewno chcesz usunąć „{name}”?\n\nTo usunie kontakt zarówno z aplikacji, jak i z towarzyszącego urządzenia radiowego.", + "@deleteContactConfirmation": { + "description": "Confirmation message for deleting contact", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "Nie udało się usunąć kontaktu: {error}", + "@failedToRemoveContact": { + "description": "Error message when contact removal fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "Typ", + "@type": { + "description": "Contact type label" + }, + "publicKey": "Klucz publiczny", + "@publicKey": { + "description": "Public key label" + }, + "lastSeen": "Ostatnio widziany", + "@lastSeen": { + "description": "Last seen label" + }, + "yes": "Tak", + "@yes": { + "description": "Yes answer" + }, + "no": "Nie", + "@no": { + "description": "No answer" + }, + "permissions": "Uprawnienia", + "@permissions": { + "description": "Permissions label" + }, + "telemetry": "Telemetria", + "@telemetry": { + "description": "Telemetry section header" + }, + "voltage": "Napięcie", + "@voltage": { + "description": "Voltage label" + }, + "battery": "Bateria", + "@battery": { + "description": "Battery label" + }, + "temperature": "Temperatura", + "@temperature": { + "description": "Temperature label" + }, + "humidity": "Wilgotność", + "@humidity": { + "description": "Humidity label" + }, + "pressure": "Ciśnienie", + "@pressure": { + "description": "Pressure label" + }, + "gpsTelemetry": "GPS (telemetria)", + "@gpsTelemetry": { + "description": "GPS from telemetry label" + }, + "reLoginToRoom": "Zaloguj ponownie do pokoju", + "@reLoginToRoom": { + "description": "Button to re-login to room" + }, + "heading": "Kierunek", + "@heading": { + "description": "Compass heading label" + }, + "elevation": "Wysokość", + "@elevation": { + "description": "Elevation/altitude label" + }, + "accuracy": "Dokładność", + "@accuracy": { + "description": "GPS accuracy label" + }, + "bearing": "Namiar", + "@bearing": { + "description": "Bearing label in compass" + }, + "direction": "Kierunek", + "@direction": { + "description": "Direction label in compass" + }, + "filterMarkers": "Filtruj znaczniki", + "@filterMarkers": { + "description": "Title for filter markers dialog" + }, + "filterMarkersTooltip": "Filtruj znaczniki", + "@filterMarkersTooltip": { + "description": "Tooltip for filter button" + }, + "contactsFilter": "Kontakty", + "@contactsFilter": { + "description": "Filter option for contacts" + }, + "repeatersFilter": "Przekaźniki", + "@repeatersFilter": { + "description": "Filter option for repeaters" + }, + "sarMarkers": "Znaczniki SAR", + "@sarMarkers": { + "description": "SAR markers section header" + }, + "foundPerson": "Odnaleziona osoba", + "@foundPerson": { + "description": "Found person SAR marker type" + }, + "fire": "Pożar", + "@fire": { + "description": "Fire SAR marker type" + }, + "stagingArea": "Punkt zbiórki", + "@stagingArea": { + "description": "Staging area SAR marker type" + }, + "showAll": "Pokaż wszystko", + "@showAll": { + "description": "Button to show all filters" + }, + "locationUnavailable": "Lokalizacja niedostępna", + "@locationUnavailable": { + "description": "Message when GPS location is unavailable" + }, + "ahead": "przed tobą", + "@ahead": { + "description": "Relative bearing direction - ahead" + }, + "degreesRight": "{degrees}° w prawo", + "@degreesRight": { + "description": "Relative bearing direction - right", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "{degrees}° w lewo", + "@degreesLeft": { + "description": "Relative bearing direction - left", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "Szer.: {latitude} Dł.: {longitude}", + "@latLonFormat": { + "description": "Latitude and longitude display format", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "Brak kontaktów", + "@noContactsYet": { + "description": "Empty state message when there are no contacts" + }, + "connectToDeviceToLoadContacts": "Połącz się z urządzeniem, aby wczytać kontakty", + "@connectToDeviceToLoadContacts": { + "description": "Instruction to connect device to load contacts" + }, + "teamMembers": "Członkowie zespołu", + "@teamMembers": { + "description": "Section header for team members (chat contacts)" + }, + "repeaters": "Przekaźniki", + "@repeaters": { + "description": "Section header for repeater nodes" + }, + "rooms": "Pokoje", + "@rooms": { + "description": "Section header for rooms" + }, + "channels": "Kanały", + "@channels": { + "description": "Section header for broadcast channels" + }, + "selectMapLayer": "Wybierz warstwę mapy", + "@selectMapLayer": { + "description": "Title for map layer selection dialog" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "OpenStreetMap layer name" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "OpenTopoMap layer name" + }, + "esriSatellite": "Satelita ESRI", + "@esriSatellite": { + "description": "ESRI Satellite imagery layer name" + }, + "googleHybrid": "Google Hybrydowa", + "@googleHybrid": { + "description": "Google Hybrid layer name (satellite + labels)" + }, + "googleRoadmap": "Google Drogowa", + "@googleRoadmap": { + "description": "Google Roadmap layer name (street map)" + }, + "googleTerrain": "Google Teren", + "@googleTerrain": { + "description": "Google Terrain layer name (topographic)" + }, + "dragToPosition": "Przeciągnij do pozycji", + "@dragToPosition": { + "description": "Label when dragging a pin on map" + }, + "createSarMarker": "Utwórz znacznik SAR", + "@createSarMarker": { + "description": "Label for creating SAR marker from pin" + }, + "compass": "Kompas", + "@compass": { + "description": "Compass title in detailed compass dialog" + }, + "navigationAndContacts": "Nawigacja i kontakty", + "@navigationAndContacts": { + "description": "Subtitle for compass dialog" + }, + "sarAlert": "ALERT SAR", + "@sarAlert": { + "description": "Label for SAR alert badge on messages" + }, + "textCopiedToClipboard": "Tekst skopiowano do schowka", + "@textCopiedToClipboard": { + "description": "Success message when text is copied" + }, + "cannotReplySenderMissing": "Nie można odpowiedzieć: brak informacji o nadawcy", + "@cannotReplySenderMissing": { + "description": "Error when sender info is missing for reply" + }, + "cannotReplyContactNotFound": "Nie można odpowiedzieć: nie znaleziono kontaktu", + "@cannotReplyContactNotFound": { + "description": "Error when contact not found for reply" + }, + "copyText": "Kopiuj tekst", + "@copyText": { + "description": "Option to copy message text to clipboard" + }, + "saveAsTemplate": "Zapisz jako szablon", + "@saveAsTemplate": { + "description": "Option to save SAR message as a reusable template" + }, + "templateSaved": "Szablon zapisano pomyślnie", + "@templateSaved": { + "description": "Success message when SAR template is saved" + }, + "templateAlreadyExists": "Szablon z tym emoji już istnieje", + "@templateAlreadyExists": { + "description": "Error message when trying to save duplicate template" + }, + "deleteMessage": "Usuń wiadomość", + "@deleteMessage": { + "description": "Dialog title for deleting a message" + }, + "deleteMessageConfirmation": "Czy na pewno chcesz usunąć tę wiadomość?", + "@deleteMessageConfirmation": { + "description": "Confirmation text for message deletion" + }, + "shareLocation": "Udostępnij lokalizację", + "@shareLocation": { + "description": "Option to share SAR marker location" + }, + "shareLocationText": "{markerInfo}\n\nWspółrzędne: {lat}, {lon}\n\nGoogle Maps: {url}", + "@shareLocationText": { + "description": "Formatted text for sharing SAR marker location", + "placeholders": { + "markerInfo": { + "type": "String" + }, + "lat": { + "type": "String" + }, + "lon": { + "type": "String" + }, + "url": { + "type": "String" + } + } + }, + "sarLocationShare": "Lokalizacja SAR", + "@sarLocationShare": { + "description": "Subject line when sharing SAR marker location" + }, + "justNow": "Przed chwilą", + "@justNow": { + "description": "Time indicator for very recent activity" + }, + "minutesAgo": "{minutes} min temu", + "@minutesAgo": { + "description": "Time indicator for minutes ago", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "{hours} godz. temu", + "@hoursAgo": { + "description": "Time indicator for hours ago", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "{days} dni temu", + "@daysAgo": { + "description": "Time indicator for days ago", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "{seconds} sek. temu", + "@secondsAgo": { + "description": "Time indicator for seconds ago", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "Wysyłanie...", + "@sending": { + "description": "Delivery status: sending" + }, + "sent": "Wysłano", + "@sent": { + "description": "Delivery status: sent" + }, + "delivered": "Dostarczono", + "@delivered": { + "description": "Delivery status: delivered" + }, + "deliveredWithTime": "Dostarczono ({time}ms)", + "@deliveredWithTime": { + "description": "Delivery status with round-trip time", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "Niepowodzenie", + "@failed": { + "description": "Delivery status: failed" + }, + "broadcast": "Nadawanie", + "@broadcast": { + "description": "Delivery status for channel messages (no echoes yet)" + }, + "deliveredToContacts": "Dostarczono do {delivered}/{total} kontaktów", + "@deliveredToContacts": { + "description": "Grouped message delivery count", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "Wszystko dostarczono", + "@allDelivered": { + "description": "Status when all recipients received the message" + }, + "recipientDetails": "Szczegóły odbiorcy", + "@recipientDetails": { + "description": "Header for expandable recipient list" + }, + "pending": "Oczekujące", + "@pending": { + "description": "Delivery status: pending/waiting" + }, + "sarMarkerFoundPerson": "Odnaleziona osoba", + "@sarMarkerFoundPerson": { + "description": "SAR marker type: found person" + }, + "sarMarkerFire": "Miejsce pożaru", + "@sarMarkerFire": { + "description": "SAR marker type: fire" + }, + "sarMarkerStagingArea": "Strefa zbiórki", + "@sarMarkerStagingArea": { + "description": "SAR marker type: staging area" + }, + "sarMarkerObject": "Odnaleziony obiekt", + "@sarMarkerObject": { + "description": "SAR marker type: object" + }, + "from": "Od", + "@from": { + "description": "Sender label in notifications" + }, + "coordinates": "Współrzędne", + "@coordinates": { + "description": "Coordinates label" + }, + "tapToViewOnMap": "Dotknij, aby zobaczyć na mapie", + "@tapToViewOnMap": { + "description": "Notification action text" + }, + "radioSettings": "Ustawienia radia", + "@radioSettings": { + "description": "Section title for radio settings" + }, + "frequencyMHz": "Częstotliwość (MHz)", + "@frequencyMHz": { + "description": "Label for radio frequency field" + }, + "bandwidth": "Szerokość pasma", + "@bandwidth": { + "description": "Label for bandwidth dropdown" + }, + "spreadingFactor": "Współczynnik rozpraszania", + "@spreadingFactor": { + "description": "Label for spreading factor dropdown" + }, + "codingRate": "Szybkość kodowania", + "@codingRate": { + "description": "Label for coding rate dropdown" + }, + "txPowerDbm": "Moc TX (dBm)", + "@txPowerDbm": { + "description": "Label for TX power field" + }, + "maxPowerDbm": "Maks: {power} dBm", + "@maxPowerDbm": { + "description": "Helper text showing maximum TX power", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "Ty", + "@you": { + "description": "Label for the current user in message bubbles" + }, + "exportFailed": "Eksport nie powiódł się: {error}", + "@exportFailed": { + "description": "Error message when export fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "Import nie powiódł się: {error}", + "@importFailed": { + "description": "Error message when import fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "Nieznane", + "@unknown": { + "description": "Unknown value label" + }, + "onlineLayers": "Warstwy online", + "@onlineLayers": { + "description": "Section header for online map layers" + }, + "locationTrail": "Ślad lokalizacji", + "@locationTrail": { + "description": "Location trail title" + }, + "showTrailOnMap": "Pokaż ślad na mapie", + "@showTrailOnMap": { + "description": "Toggle to show/hide trail on map" + }, + "trailVisible": "Ślad jest widoczny na mapie", + "@trailVisible": { + "description": "Trail visibility status - visible" + }, + "trailHiddenRecording": "Ślad jest ukryty (nadal nagrywany)", + "@trailHiddenRecording": { + "description": "Trail visibility status - hidden but recording" + }, + "duration": "Czas trwania", + "@duration": { + "description": "Duration label" + }, + "points": "Punkty", + "@points": { + "description": "Trail points count label" + }, + "clearTrail": "Wyczyść ślad", + "@clearTrail": { + "description": "Button to clear location trail" + }, + "clearTrailQuestion": "Wyczyścić ślad?", + "@clearTrailQuestion": { + "description": "Confirmation dialog title" + }, + "clearTrailConfirmation": "Czy na pewno chcesz wyczyścić bieżący ślad lokalizacji? Tego działania nie można cofnąć.", + "@clearTrailConfirmation": { + "description": "Confirmation dialog message" + }, + "noTrailRecorded": "Brak zapisanego śladu", + "@noTrailRecorded": { + "description": "Message when no trail exists" + }, + "startTrackingToRecord": "Rozpocznij śledzenie lokalizacji, aby zapisać ślad", + "@startTrackingToRecord": { + "description": "Instructions to start trail recording" + }, + "trailControls": "Sterowanie śladem", + "@trailControls": { + "description": "Trail controls tooltip" + }, + "model": "Model", + "@model": { + "description": "Device model label" + }, + "version": "Wersja", + "@version": { + "description": "Version label" + }, + "maxContacts": "Maks. kontaktów", + "@maxContacts": { + "description": "Maximum contacts capacity label" + }, + "maxChannels": "Maks. kanałów", + "@maxChannels": { + "description": "Maximum channels capacity label" + }, + "publicInfo": "Informacje publiczne", + "@publicInfo": { + "description": "Public information section header" + }, + "useCurrentLocation": "Użyj bieżącej lokalizacji", + "@useCurrentLocation": { + "description": "Tooltip for use current location button" + }, + "noneUnknown": "Brak/Nieznane", + "@noneUnknown": { + "description": "Device type: none or unknown" + }, + "chatNode": "Węzeł czatu", + "@chatNode": { + "description": "Device type: chat node" + }, + "repeater": "Przekaźnik", + "@repeater": { + "description": "Device type: repeater" + }, + "roomChannel": "Pokój/Kanał", + "@roomChannel": { + "description": "Device type: room or channel" + }, + "typeNumber": "Typ {number}", + "@typeNumber": { + "description": "Generic device type with number", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "Nie udało się zapisać: {error}", + "@failedToSave": { + "description": "Generic error message for save failures", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "Nie udało się pobrać lokalizacji: {error}", + "@failedToGetLocation": { + "description": "Error message when getting location fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "Szablony SAR", + "@sarTemplates": { + "description": "SAR templates menu title" + }, + "addTemplate": "Dodaj szablon", + "@addTemplate": { + "description": "Button to add new SAR template" + }, + "editTemplate": "Edytuj szablon", + "@editTemplate": { + "description": "Dialog title for editing template" + }, + "deleteTemplate": "Usuń szablon", + "@deleteTemplate": { + "description": "Action to delete template" + }, + "templateName": "Nazwa szablonu", + "@templateName": { + "description": "Label for template name field" + }, + "templateNameHint": "np. Odnaleziona osoba", + "@templateNameHint": { + "description": "Hint text for template name" + }, + "templateEmoji": "Emoji", + "@templateEmoji": { + "description": "Label for template emoji field" + }, + "emojiRequired": "Emoji jest wymagane", + "@emojiRequired": { + "description": "Validation error when emoji field is empty" + }, + "nameRequired": "Nazwa jest wymagana", + "@nameRequired": { + "description": "Validation error when name field is empty" + }, + "templateDescription": "Opis (opcjonalnie)", + "@templateDescription": { + "description": "Label for template description field" + }, + "templateDescriptionHint": "Dodaj dodatkowy kontekst...", + "@templateDescriptionHint": { + "description": "Hint text for template description" + }, + "templateColor": "Kolor", + "@templateColor": { + "description": "Label for template color picker" + }, + "previewFormat": "Podgląd (format wiadomości SAR)", + "@previewFormat": { + "description": "Label for format preview" + }, + "importFromClipboard": "Importuj", + "@importFromClipboard": { + "description": "Button to import templates from clipboard" + }, + "exportToClipboard": "Eksportuj", + "@exportToClipboard": { + "description": "Button to export templates to clipboard" + }, + "deleteTemplateConfirmation": "Usunąć szablon „{name}”?", + "@deleteTemplateConfirmation": { + "description": "Confirmation message for template deletion", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "templateAdded": "Dodano szablon", + "@templateAdded": { + "description": "Success message when template is added" + }, + "templateUpdated": "Zaktualizowano szablon", + "@templateUpdated": { + "description": "Success message when template is updated" + }, + "templateDeleted": "Usunięto szablon", + "@templateDeleted": { + "description": "Success message when template is deleted" + }, + "templatesImported": "{count, plural, =0{Nie zaimportowano szablonów} =1{Zaimportowano 1 szablon} other{Zaimportowano {count} szablonów}}", + "@templatesImported": { + "description": "Success message after importing templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "templatesExported": "{count, plural, =1{Wyeksportowano 1 szablon do schowka} other{Wyeksportowano {count} szablonów do schowka}}", + "@templatesExported": { + "description": "Success message after exporting templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "resetToDefaults": "Przywróć domyślne", + "@resetToDefaults": { + "description": "Action to reset templates to defaults" + }, + "resetToDefaultsConfirmation": "To usunie wszystkie własne szablony i przywróci 4 domyślne szablony. Kontynuować?", + "@resetToDefaultsConfirmation": { + "description": "Confirmation message for reset to defaults" + }, + "reset": "Resetuj", + "@reset": { + "description": "Reset button label" + }, + "resetComplete": "Szablony przywrócono do domyślnych", + "@resetComplete": { + "description": "Success message after reset" + }, + "noTemplates": "Brak dostępnych szablonów", + "@noTemplates": { + "description": "Message when no templates exist" + }, + "tapAddToCreate": "Dotknij +, aby utworzyć pierwszy szablon", + "@tapAddToCreate": { + "description": "Helper text when no templates exist" + }, + "ok": "OK", + "@ok": { + "description": "OK button label" + }, + "locationPermission": "Uprawnienie lokalizacji", + "@locationPermission": { + "description": "Location permission label" + }, + "checking": "Sprawdzanie...", + "@checking": { + "description": "Loading state indicator" + }, + "locationPermissionGrantedAlways": "Przyznano (zawsze)", + "@locationPermissionGrantedAlways": { + "description": "Location permission status: granted always" + }, + "locationPermissionGrantedWhileInUse": "Przyznano (podczas użycia)", + "@locationPermissionGrantedWhileInUse": { + "description": "Location permission status: granted while in use" + }, + "locationPermissionDeniedTapToRequest": "Odrzucono - dotknij, aby poprosić", + "@locationPermissionDeniedTapToRequest": { + "description": "Location permission status: denied, user can request" + }, + "locationPermissionPermanentlyDeniedOpenSettings": "Trwale odrzucono - otwórz ustawienia", + "@locationPermissionPermanentlyDeniedOpenSettings": { + "description": "Location permission status: permanently denied" + }, + "locationPermissionDialogContent": "Dostęp do lokalizacji został trwale odrzucony. Włącz go w ustawieniach urządzenia, aby używać GPS i udostępniania lokalizacji.", + "@locationPermissionDialogContent": { + "description": "Content for location permission dialog when permanently denied" + }, + "openSettings": "Otwórz ustawienia", + "@openSettings": { + "description": "Button to open device settings" + }, + "locationPermissionGranted": "Przyznano uprawnienie lokalizacji!", + "@locationPermissionGranted": { + "description": "Success message when location permission is granted" + }, + "locationPermissionRequiredForGps": "Dostęp do lokalizacji jest wymagany do śledzenia GPS i udostępniania lokalizacji.", + "@locationPermissionRequiredForGps": { + "description": "Info message about location permission requirement" + }, + "locationPermissionAlreadyGranted": "Dostęp do lokalizacji został już przyznany.", + "@locationPermissionAlreadyGranted": { + "description": "Info message when permission is already granted" + }, + "sarNavyBlue": "SAR Granatowy", + "@sarNavyBlue": { + "description": "SAR Navy Blue theme name" + }, + "sarNavyBlueDescription": "Tryb profesjonalny/operacyjny", + "@sarNavyBlueDescription": { + "description": "Description for SAR Navy Blue theme" + }, + "selectRecipient": "Wybierz odbiorcę", + "@selectRecipient": { + "description": "Title for recipient selector sheet" + }, + "broadcastToAllNearby": "Nadaj do wszystkich w pobliżu", + "@broadcastToAllNearby": { + "description": "Subtitle for public channel option" + }, + "searchRecipients": "Szukaj odbiorców...", + "@searchRecipients": { + "description": "Placeholder text for recipient search field" + }, + "noContactsFound": "Nie znaleziono kontaktów", + "@noContactsFound": { + "description": "Message when no contacts match search" + }, + "noRoomsFound": "Nie znaleziono pokoi", + "@noRoomsFound": { + "description": "Message when no rooms match search" + }, + "noRecipientsAvailable": "Brak dostępnych odbiorców", + "@noRecipientsAvailable": { + "description": "Message when no recipients exist (contacts, rooms, or channels)" + }, + "noChannelsFound": "Nie znaleziono kanałów", + "@noChannelsFound": { + "description": "Message when no channels match the search" + }, + "newMessage": "Nowa wiadomość", + "@newMessage": { + "description": "Notification title for new message" + }, + "channel": "Kanał", + "@channel": { + "description": "Channel label in notifications" + }, + "samplePoliceLead": "Dowódca policji", + "@samplePoliceLead": { + "description": "Sample team member name" + }, + "sampleDroneOperator": "Operator drona", + "@sampleDroneOperator": { + "description": "Sample team member name" + }, + "sampleFirefighterAlpha": "Strażak", + "@sampleFirefighterAlpha": { + "description": "Sample team member name" + }, + "sampleMedicCharlie": "Ratownik medyczny", + "@sampleMedicCharlie": { + "description": "Sample team member name" + }, + "sampleCommandDelta": "Dowództwo", + "@sampleCommandDelta": { + "description": "Sample team member name" + }, + "sampleFireEngine": "Wóz strażacki", + "@sampleFireEngine": { + "description": "Sample team member name" + }, + "sampleAirSupport": "Wsparcie lotnicze", + "@sampleAirSupport": { + "description": "Sample team member name" + }, + "sampleBaseCoordinator": "Koordynator bazy", + "@sampleBaseCoordinator": { + "description": "Sample team member name" + }, + "channelEmergency": "Alarmowy", + "@channelEmergency": { + "description": "Emergency channel name" + }, + "channelCoordination": "Koordynacja", + "@channelCoordination": { + "description": "Coordination channel name" + }, + "channelUpdates": "Aktualizacje", + "@channelUpdates": { + "description": "Updates channel name" + }, + "sampleTeamMember": "Przykładowy członek zespołu", + "@sampleTeamMember": { + "description": "Sample sender name" + }, + "sampleScout": "Przykładowy zwiadowca", + "@sampleScout": { + "description": "Sample sender name" + }, + "sampleBase": "Przykładowa baza", + "@sampleBase": { + "description": "Sample sender name" + }, + "sampleSearcher": "Przykładowy poszukiwacz", + "@sampleSearcher": { + "description": "Sample sender name" + }, + "sampleObjectBackpack": " Znaleziono plecak - kolor niebieski", + "@sampleObjectBackpack": { + "description": "Sample object note" + }, + "sampleObjectVehicle": " Porzucony pojazd - sprawdzić właściciela", + "@sampleObjectVehicle": { + "description": "Sample object note" + }, + "sampleObjectCamping": " Znaleziono sprzęt kempingowy", + "@sampleObjectCamping": { + "description": "Sample object note" + }, + "sampleObjectTrailMarker": " Znaleziono znacznik szlaku poza trasą", + "@sampleObjectTrailMarker": { + "description": "Sample object note" + }, + "sampleMsgAllTeamsCheckIn": "Wszystkie zespoły - meldunek", + "@sampleMsgAllTeamsCheckIn": { + "description": "Sample channel message" + }, + "sampleMsgWeatherUpdate": "Aktualizacja pogody: bezchmurnie, temp. 18°C", + "@sampleMsgWeatherUpdate": { + "description": "Sample channel message" + }, + "sampleMsgBaseCamp": "Bazę założono w strefie zbiórki", + "@sampleMsgBaseCamp": { + "description": "Sample channel message" + }, + "sampleMsgTeamAlpha": "Zespół przemieszcza się do sektora 2", + "@sampleMsgTeamAlpha": { + "description": "Sample channel message" + }, + "sampleMsgRadioCheck": "Kontrola radiowa - wszystkie stacje proszone o odpowiedź", + "@sampleMsgRadioCheck": { + "description": "Sample channel message" + }, + "sampleMsgWaterSupply": "Woda dostępna w punkcie kontrolnym 3", + "@sampleMsgWaterSupply": { + "description": "Sample channel message" + }, + "sampleMsgTeamBravo": "Zespół melduje: sektor 1 czysty", + "@sampleMsgTeamBravo": { + "description": "Sample channel message" + }, + "sampleMsgEtaRallyPoint": "Szacowany czas dotarcia do punktu zbiórki: 15 minut", + "@sampleMsgEtaRallyPoint": { + "description": "Sample channel message" + }, + "sampleMsgSupplyDrop": "Zrzut zaopatrzenia potwierdzony na 14:00", + "@sampleMsgSupplyDrop": { + "description": "Sample channel message" + }, + "sampleMsgDroneSurvey": "Rozpoznanie dronem zakończone - brak wyników", + "@sampleMsgDroneSurvey": { + "description": "Sample channel message" + }, + "sampleMsgTeamCharlie": "Zespół prosi o wsparcie", + "@sampleMsgTeamCharlie": { + "description": "Sample channel message" + }, + "sampleMsgRadioDiscipline": "Wszystkie jednostki: zachować dyscyplinę radiową", + "@sampleMsgRadioDiscipline": { + "description": "Sample channel message" + }, + "sampleMsgUrgentMedical": "PILNE: potrzebna pomoc medyczna w sektorze 4", + "@sampleMsgUrgentMedical": { + "description": "Sample emergency message" + }, + "sampleMsgAdultMale": " Dorosły mężczyzna, przytomny", + "@sampleMsgAdultMale": { + "description": "Sample emergency message note" + }, + "sampleMsgFireSpotted": "Wykryto pożar - współrzędne w drodze", + "@sampleMsgFireSpotted": { + "description": "Sample emergency message" + }, + "sampleMsgSpreadingRapidly": " Szybko się rozprzestrzenia!", + "@sampleMsgSpreadingRapidly": { + "description": "Sample emergency message note" + }, + "sampleMsgPriorityHelicopter": "PRIORYTET: potrzebne wsparcie śmigłowca", + "@sampleMsgPriorityHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgMedicalTeamEnRoute": "Zespół medyczny jest w drodze do twojej lokalizacji", + "@sampleMsgMedicalTeamEnRoute": { + "description": "Sample emergency message" + }, + "sampleMsgEvacHelicopter": "ETA śmigłowca ewakuacyjnego 10 minut", + "@sampleMsgEvacHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyResolved": "Sytuacja opanowana - wszystko jasne", + "@sampleMsgEmergencyResolved": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyStagingArea": " Strefa zbiórki awaryjnej", + "@sampleMsgEmergencyStagingArea": { + "description": "Sample emergency message note" + }, + "sampleMsgEmergencyServices": "Służby ratunkowe zostały powiadomione i jadą na miejsce", + "@sampleMsgEmergencyServices": { + "description": "Sample emergency message" + }, + "sampleAlphaTeamLead": "Lider zespołu", + "@sampleAlphaTeamLead": { + "description": "Sample team name" + }, + "sampleBravoScout": "Zwiadowca", + "@sampleBravoScout": { + "description": "Sample team name" + }, + "sampleCharlieMedic": "Ratownik medyczny", + "@sampleCharlieMedic": { + "description": "Sample team name" + }, + "sampleDeltaNavigator": "Nawigator", + "@sampleDeltaNavigator": { + "description": "Sample team name" + }, + "sampleEchoSupport": "Wsparcie", + "@sampleEchoSupport": { + "description": "Sample team name" + }, + "sampleBaseCommand": "Dowództwo bazy", + "@sampleBaseCommand": { + "description": "Sample team name" + }, + "sampleFieldCoordinator": "Koordynator terenowy", + "@sampleFieldCoordinator": { + "description": "Sample team name" + }, + "sampleMedicalTeam": "Zespół medyczny", + "@sampleMedicalTeam": { + "description": "Sample team name" + }, + "mapDrawing": "Rysunek mapy", + "@mapDrawing": { + "description": "Label for map drawing messages" + }, + "navigateToDrawing": "Nawiguj do rysunku", + "@navigateToDrawing": { + "description": "Option to navigate to drawing on map" + }, + "copyCoordinates": "Kopiuj współrzędne", + "@copyCoordinates": { + "description": "Option to copy coordinates to clipboard" + }, + "hideFromMap": "Ukryj na mapie", + "@hideFromMap": { + "description": "Option to hide drawing from map" + }, + "lineDrawing": "Rysunek linii", + "@lineDrawing": { + "description": "Label for line type drawings" + }, + "rectangleDrawing": "Rysunek prostokąta", + "@rectangleDrawing": { + "description": "Label for rectangle type drawings" + }, + "manualCoordinates": "Współrzędne ręczne", + "@manualCoordinates": { + "description": "Label for manual coordinate input toggle" + }, + "enterCoordinatesManually": "Wprowadź współrzędne ręcznie", + "@enterCoordinatesManually": { + "description": "Description for manual coordinate input option" + }, + "latitudeLabel": "Szerokość geograficzna", + "@latitudeLabel": { + "description": "Label for latitude input field" + }, + "longitudeLabel": "Długość geograficzna", + "@longitudeLabel": { + "description": "Label for longitude input field" + }, + "exampleCoordinates": "Przykład: 46.0569, 14.5058", + "@exampleCoordinates": { + "description": "Example coordinate format hint" + }, + "shareDrawing": "Udostępnij rysunek", + "@shareDrawing": { + "description": "Title for share single drawing dialog" + }, + "shareWithAllNearbyDevices": "Udostępnij wszystkim pobliskim urządzeniom", + "@shareWithAllNearbyDevices": { + "description": "Subtitle for public channel sharing option" + }, + "shareToRoom": "Udostępnij do pokoju", + "@shareToRoom": { + "description": "Header for room sharing section" + }, + "sendToPersistentStorage": "Wyślij do trwałego magazynu pokoju", + "@sendToPersistentStorage": { + "description": "Subtitle for room sharing option" + }, + "deleteDrawingConfirm": "Czy na pewno chcesz usunąć ten rysunek?", + "@deleteDrawingConfirm": { + "description": "Confirmation message for deleting a single drawing" + }, + "drawingDeleted": "Rysunek usunięto", + "@drawingDeleted": { + "description": "Success message after deleting a drawing" + }, + "yourDrawingsCount": "Twoje rysunki ({count})", + "@yourDrawingsCount": { + "description": "Header showing count of user's drawings", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shared": "Udostępniono", + "@shared": { + "description": "Status label for shared drawings" + }, + "line": "Linia", + "@line": { + "description": "Line drawing type label" + }, + "rectangle": "Prostokąt", + "@rectangle": { + "description": "Rectangle drawing type label" + }, + "updateAvailable": "Dostępna aktualizacja", + "@updateAvailable": { + "description": "Title for update dialog when new version is available" + }, + "currentVersion": "Bieżąca", + "@currentVersion": { + "description": "Label for current app version" + }, + "latestVersion": "Najnowsza", + "@latestVersion": { + "description": "Label for latest available app version" + }, + "downloadUpdate": "Pobierz", + "@downloadUpdate": { + "description": "Button to download app update" + }, + "updateLater": "Później", + "@updateLater": { + "description": "Button to dismiss update dialog" + }, + "cadastralParcels": "Działki katastralne", + "@cadastralParcels": { + "description": "Label for cadastral parcels WMS overlay layer" + }, + "forestRoads": "Drogi leśne", + "@forestRoads": { + "description": "Label for forest roads WMS overlay layer" + }, + "wmsOverlays": "Nakładki WMS", + "@wmsOverlays": { + "description": "Section header for WMS overlay layers in layer selector" + }, + "hikingTrails": "Szlaki piesze", + "@hikingTrails": { + "description": "Label for hiking/mountain trails WMS overlay layer" + }, + "mainRoads": "Drogi główne", + "@mainRoads": { + "description": "Label for main roads WMS overlay layer" + }, + "houseNumbers": "Numery budynków", + "@houseNumbers": { + "description": "Label for house numbers WMS overlay layer" + }, + "fireHazardZones": "Strefy zagrożenia pożarowego", + "@fireHazardZones": { + "description": "Label for fire hazard risk zones WMS overlay layer" + }, + "historicalFires": "Historyczne pożary", + "@historicalFires": { + "description": "Label for historical forest fires WMS overlay layer" + }, + "firebreaks": "Pasy przeciwpożarowe", + "@firebreaks": { + "description": "Label for firebreaks WMS overlay layer" + }, + "krasFireZones": "Strefy pożarowe Krasu", + "@krasFireZones": { + "description": "Label for Kras fire zones WMS overlay layer" + }, + "placeNames": "Nazwy miejsc", + "@placeNames": { + "description": "Label for geographic place names WMS overlay layer" + }, + "municipalityBorders": "Granice gmin", + "@municipalityBorders": { + "description": "Label for municipality borders WMS overlay layer" + }, + "topographicMap": "Mapa topograficzna 1:25000", + "@topographicMap": { + "description": "Label for DTK25 topographic base map layer" + }, + "recentMessages": "Ostatnie wiadomości", + "@recentMessages": { + "description": "Header for recent messages overlay on map in fullscreen mode" + }, + "addChannel": "Dodaj kanał", + "@addChannel": { + "description": "Button to add a new channel" + }, + "channelName": "Nazwa kanału", + "@channelName": { + "description": "Label for channel name field" + }, + "channelNameHint": "np. Zespół Ratunkowy Alfa", + "@channelNameHint": { + "description": "Hint for channel name field" + }, + "channelSecret": "Sekret kanału", + "@channelSecret": { + "description": "Label for channel secret field" + }, + "channelSecretHint": "Wspólne hasło dla tego kanału", + "@channelSecretHint": { + "description": "Hint for channel secret field" + }, + "channelSecretHelp": "Ten sekret musi być współdzielony ze wszystkimi członkami zespołu, którzy potrzebują dostępu do tego kanału", + "@channelSecretHelp": { + "description": "Help text explaining channel secret" + }, + "channelTypesInfo": "Kanały hash (#team): sekret jest generowany automatycznie z nazwy. Ta sama nazwa = ten sam kanał na wszystkich urządzeniach.\n\nKanały prywatne: użyj jawnego sekretu. Dołączyć mogą tylko osoby znające sekret.", + "@channelTypesInfo": { + "description": "Information banner explaining hash and private channel types" + }, + "hashChannelInfo": "Kanał hash: sekret zostanie wygenerowany automatycznie z nazwy kanału. Każdy używający tej samej nazwy dołączy do tego samego kanału.", + "@hashChannelInfo": { + "description": "Help text for hash channels (# prefix)" + }, + "channelNameRequired": "Nazwa kanału jest wymagana", + "@channelNameRequired": { + "description": "Validation error for empty channel name" + }, + "channelNameTooLong": "Nazwa kanału może mieć maksymalnie 31 znaków", + "@channelNameTooLong": { + "description": "Validation error for channel name too long" + }, + "channelSecretRequired": "Sekret kanału jest wymagany", + "@channelSecretRequired": { + "description": "Validation error for empty channel secret" + }, + "channelSecretTooLong": "Sekret kanału może mieć maksymalnie 32 znaki", + "@channelSecretTooLong": { + "description": "Validation error for channel secret too long" + }, + "invalidAsciiCharacters": "Dozwolone są tylko znaki ASCII", + "@invalidAsciiCharacters": { + "description": "Validation error for non-ASCII characters" + }, + "channelCreatedSuccessfully": "Kanał utworzono pomyślnie", + "@channelCreatedSuccessfully": { + "description": "Success message after creating channel" + }, + "channelCreationFailed": "Nie udało się utworzyć kanału: {error}", + "@channelCreationFailed": { + "description": "Error message when channel creation fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deleteChannel": "Usuń kanał", + "@deleteChannel": { + "description": "Delete channel button/menu item" + }, + "deleteChannelConfirmation": "Czy na pewno chcesz usunąć kanał „{channelName}”? Tej operacji nie można cofnąć.", + "@deleteChannelConfirmation": { + "description": "Confirmation dialog when deleting a channel", + "placeholders": { + "channelName": { + "type": "String" + } + } + }, + "channelDeletedSuccessfully": "Kanał usunięto pomyślnie", + "@channelDeletedSuccessfully": { + "description": "Success message after deleting channel" + }, + "channelDeletionFailed": "Nie udało się usunąć kanału: {error}", + "@channelDeletionFailed": { + "description": "Error message when channel deletion fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "createChannel": "Utwórz kanał", + "@createChannel": { + "description": "Button text for creating a channel" + }, + "wizardBack": "Wstecz", + "@wizardBack": { + "description": "Wizard back button text" + }, + "wizardSkip": "Pomiń", + "@wizardSkip": { + "description": "Wizard skip button text" + }, + "wizardNext": "Dalej", + "@wizardNext": { + "description": "Wizard next button text" + }, + "wizardGetStarted": "Zaczynaj", + "@wizardGetStarted": { + "description": "Wizard final button text to complete onboarding" + }, + "wizardWelcomeTitle": "Witamy w MeshCore SAR", + "@wizardWelcomeTitle": { + "description": "Welcome wizard first page title" + }, + "viewWelcomeTutorial": "Pokaż samouczek powitalny", + "@viewWelcomeTutorial": { + "description": "Settings option to re-show welcome wizard" + }, + "allTeamContacts": "Wszystkie kontakty zespołu", + "@allTeamContacts": { + "description": "Destination option to send SAR marker to all team contacts" + }, + "directMessagesInfo": "Wiadomości bezpośrednie z ACK. Wysłano do {count} członków zespołu.", + "@directMessagesInfo": { + "description": "Information about sending to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "sarMarkerSentToContacts": "Znacznik SAR wysłano do {count} kontaktów", + "@sarMarkerSentToContacts": { + "description": "Success message after sending SAR marker to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noContactsAvailable": "Brak kontaktów zespołu", + "@noContactsAvailable": { + "description": "Message when there are no chat contacts to send to" + }, + "reply": "Odpowiedz", + "@reply": {}, + "technicalDetails": "Szczegóły techniczne", + "@technicalDetails": {}, + "messageTechnicalDetails": "Szczegóły techniczne wiadomości", + "@messageTechnicalDetails": {}, + "linkQuality": "Jakość łącza", + "@linkQuality": {}, + "delivery": "Dostarczenie", + "@delivery": {}, + "status": "Status", + "@status": {}, + "expectedAckTag": "Oczekiwany znacznik ACK", + "@expectedAckTag": {}, + "roundTrip": "Czas podróży w obie strony", + "@roundTrip": {}, + "retryAttempt": "Próba ponowienia", + "@retryAttempt": {}, + "floodFallback": "Awaryjny flooding", + "@floodFallback": {}, + "identity": "Tożsamość", + "@identity": {}, + "messageId": "ID wiadomości", + "@messageId": {}, + "sender": "Nadawca", + "@sender": {}, + "senderKey": "Klucz nadawcy", + "@senderKey": {}, + "recipient": "Odbiorca", + "@recipient": {}, + "recipientKey": "Klucz odbiorcy", + "@recipientKey": {}, + "voice": "Głos", + "@voice": {}, + "voiceId": "ID głosu", + "@voiceId": {}, + "envelope": "Koperta", + "@envelope": {}, + "sessionProgress": "Postęp sesji", + "@sessionProgress": {}, + "complete": "Zakończono", + "@complete": {}, + "rawDump": "Surowy zrzut", + "@rawDump": {}, + "cannotRetryMissingRecipient": "Nie można ponowić: brak informacji o odbiorcy", + "@cannotRetryMissingRecipient": {}, + "voiceUnavailable": "Głos jest obecnie niedostępny", + "@voiceUnavailable": {}, + "requestingVoice": "Pobieranie głosu", + "@requestingVoice": {}, + "device": "urządzenie", + "change": "Zmień", + "wizardOverviewDescription": "Ta aplikacja łączy wiadomości MeshCore, terenowe aktualizacje SAR, mapy i narzędzia urządzenia w jednym miejscu.", + "wizardOverviewFeature1": "Wysyłaj wiadomości bezpośrednie, wpisy pokojowe i wiadomości kanałowe z głównej karty Wiadomości.", + "wizardOverviewFeature2": "Udostępniaj znaczniki SAR, rysunki mapy, klipy głosowe i obrazy przez sieć mesh.", + "wizardOverviewFeature3": "Połącz się przez BLE lub TCP, a następnie zarządzaj radiem towarzyszącym bezpośrednio z aplikacji.", + "wizardMessagingTitle": "Wiadomości i raporty terenowe", + "wizardMessagingDescription": "Wiadomości to tutaj coś więcej niż zwykły tekst. Aplikacja obsługuje już kilka typów danych operacyjnych i przepływów transferu.", + "wizardMessagingFeature1": "Wysyłaj wiadomości bezpośrednie, wpisy pokojowe i ruch kanałowy z jednego edytora.", + "wizardMessagingFeature2": "Twórz aktualizacje SAR i wielokrotnego użytku szablony SAR do typowych raportów terenowych.", + "wizardMessagingFeature3": "Przesyłaj sesje głosowe i obrazy z postępem i szacowanym czasem transmisji w interfejsie.", + "wizardConnectDeviceTitle": "Połącz urządzenie", + "wizardConnectDeviceDescription": "Podłącz radio MeshCore, wybierz nazwę i zastosuj profil radiowy przed kontynuacją.", + "wizardSetupBadge": "Konfiguracja", + "wizardOverviewBadge": "Przegląd", + "wizardConnectedToDevice": "Połączono z {deviceName}", + "wizardNoDeviceConnected": "Brak podłączonego urządzenia", + "wizardSkipForNow": "Pomiń na razie", + "wizardDeviceNameLabel": "Nazwa urządzenia", + "wizardDeviceNameHelp": "Ta nazwa jest rozgłaszana innym użytkownikom MeshCore.", + "wizardConfigRegionLabel": "Region konfiguracji", + "wizardConfigRegionHelp": "Używa pełnej oficjalnej listy presetów MeshCore. Domyślnie wybrane jest EU/UK (Narrow).", + "wizardPresetNote1": "Upewnij się, że wybrany preset jest zgodny z lokalnymi przepisami radiowymi.", + "wizardPresetNote2": "Lista odpowiada oficjalnemu źródłu presetów narzędzia konfiguracji MeshCore.", + "wizardPresetNote3": "EU/UK (Narrow) pozostaje domyślnie wybrane podczas wdrożenia.", + "wizardSaving": "Zapisywanie...", + "wizardSaveAndContinue": "Zapisz i kontynuuj", + "wizardEnterDeviceName": "Wprowadź nazwę urządzenia przed kontynuacją.", + "wizardDeviceSetupSaved": "Zapisano {deviceName} z ustawieniem {presetName}.", + "wizardNetworkTitle": "Kontakty, pokoje i repeatery", + "wizardNetworkDescription": "Karta Kontakty porządkuje sieć, którą odkrywasz, oraz trasy, których uczysz się z czasem.", + "wizardNetworkFeature1": "Przeglądaj członków zespołu, repeatery, pokoje, kanały i oczekujące ogłoszenia na jednej liście.", + "wizardNetworkFeature2": "Używaj smart ping, logowania do pokoi, poznanych ścieżek i narzędzi resetowania tras, gdy łączność staje się chaotyczna.", + "wizardNetworkFeature3": "Twórz kanały i zarządzaj celami sieciowymi bez opuszczania aplikacji.", + "wizardMapOpsTitle": "Mapa, ślady i współdzielona geometria", + "wizardMapOpsDescription": "Mapa aplikacji jest bezpośrednio powiązana z wiadomościami, śledzeniem i nakładkami SAR zamiast być osobnym podglądem.", + "wizardMapOpsFeature1": "Śledź własną pozycję, lokalizacje członków zespołu i ślady ruchu na mapie.", + "wizardMapOpsFeature2": "Otwieraj rysunki z wiadomości, podglądaj je w miejscu i usuwaj z mapy w razie potrzeby.", + "wizardMapOpsFeature3": "Korzystaj z widoków map repeaterów i współdzielonych nakładek, aby zrozumieć zasięg sieci w terenie.", + "wizardToolsTitle": "Narzędzia poza wiadomościami", + "wizardToolsDescription": "Jest tu więcej niż cztery główne karty. Aplikacja obejmuje także konfigurację, diagnostykę i opcjonalne przepływy czujników.", + "wizardToolsFeature1": "Otwórz konfigurację urządzenia, aby zmienić ustawienia radia, telemetrię, moc TX i szczegóły urządzenia towarzyszącego.", + "wizardToolsFeature2": "Włącz kartę Czujniki, gdy chcesz mieć pulpity monitorowanych czujników i szybkie odświeżanie.", + "wizardToolsFeature3": "Używaj logów pakietów, skanowania widma i diagnostyki deweloperskiej podczas rozwiązywania problemów z mesh.", + "contactInSensors": "W Czujnikach", + "contactAddToSensors": "Dodaj do Czujników", + "contactSetPath": "Ustaw ścieżkę", + "contactAddedToSensors": "{contactName} dodano do Czujników", + "contactFailedToClearRoute": "Nie udało się wyczyścić trasy: {error}", + "contactRouteCleared": "Trasa wyczyszczona", + "contactRouteSet": "Ustawiono trasę: {route}", + "contactFailedToSetRoute": "Nie udało się ustawić trasy: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "Limit czasu ACK", + "opcode": "Opcode", + "payload": "Ładunek", + "hops": "Skoki", + "hashSize": "Rozmiar hash", + "pathBytes": "Bajty ścieżki", + "selectedPath": "Wybrana ścieżka", + "estimatedTx": "Szacowane nadawanie", + "senderToReceipt": "Od nadawcy do odbioru", + "receivedCopies": "Otrzymane kopie", + "retryCause": "Przyczyna ponowienia", + "retryMode": "Tryb ponowienia", + "retryResult": "Wynik ponowienia", + "lastRetry": "Ostatnia próba", + "rxPackets": "Pakiety RX", + "mesh": "Mesh", + "rate": "Szybkość", + "window": "Okno", + "posttxDelay": "Opóźnienie po nadawaniu", + "bandpass": "Pasmowy", + "bandpassFilterVoice": "Filtr pasmowy głosu", + "active": "Aktywny", + "addContact": "Dodaj kontakt", + "all": "Wszystko", + "clearAllLabel": "Wyczyść wszystko", + "clearFilters": "Wyczyść filtry", + "clearRoute": "Wyczyść trasę", + "clearMessages": "Wyczyść wiadomości", + "clearScale": "Wyczyść skalę", + "clearDiscoveries": "Wyczyść odkrycia", + "clearOnlineTraceDatabase": "Wyczyść bazę śladów", + "clearAllChannels": "Wyczyść wszystkie kanały", + "clearAllContacts": "Wyczyść wszystkie kontakty", + "clearChannels": "Wyczyść kanały", + "clearContacts": "Wyczyść kontakty", + "clearPathOnMaxRetry": "Wyczyść ścieżkę przy maks. próbie", + "create": "Utwórz", + "custom": "Niestandardowy", + "defaultValue": "Domyślny", + "duplicate": "Duplikuj", + "editName": "Edytuj nazwę", + "open": "Otwórz", + "paste": "Wklej", + "preview": "Podgląd", + "remove": "Usuń", + "rename": "Zmień nazwę", + "resolveAll": "Rozwiąż wszystko", + "send": "Wyślij", + "sendAnyway": "Wyślij mimo to", + "share": "Udostępnij", + "trace": "Śledzenie", + "discovery": "Wykrywanie", + "discoverRepeaters": "Wykryj przekaźniki", + "discoverSensors": "Wykryj czujniki", + "repeaterDiscoverySent": "Wykrywanie przekaźników wysłane", + "sensorDiscoverySent": "Wykrywanie czujników wysłane", + "clearedPendingDiscoveries": "Wyczyszczono oczekujące odkrycia.", + "autoDiscovery": "Automatyczne wykrywanie", + "enableAutomaticAdding": "Włącz automatyczne dodawanie", + "autoaddRepeaters": "Automatycznie dodaj przekaźniki", + "autoaddRoomServers": "Automatycznie dodaj serwery pokoi", + "autoaddSensors": "Automatycznie dodaj czujniki", + "autoaddUsers": "Automatycznie dodaj użytkowników", + "overwriteOldestWhenFull": "Nadpisz najstarsze gdy pełne", + "storage": "Pamięć", + "dangerZone": "Strefa zagrożenia", + "profiles": "Profile", + "favourites": "Ulubione", + "sensors": "Czujniki", + "others": "Inne", + "gpsModule": "Moduł GPS", + "liveTraffic": "Ruch na żywo", + "repeatersMap": "Mapa przekaźników", + "blePacketLogs": "Logi pakietów BLE", + "onlineTraceDatabase": "Baza śladów", + "routePathByteSize": "Rozmiar ścieżki w bajtach", + "messageNotifications": "Powiadomienia o wiadomościach", + "sarAlerts": "Alerty SAR", + "discoveryNotifications": "Powiadomienia o wykrywaniu", + "updateNotifications": "Powiadomienia o aktualizacjach", + "muteWhileAppIsOpen": "Wycisz gdy aplikacja jest otwarta", + "disableContacts": "Wyłącz kontakty", + "enableSensorsTab": "Włącz kartę Czujniki", + "enableProfiles": "Włącz profile", + "nearestRepeaterFallback": "Najbliższy przekaźnik awaryjny", + "deleteAllStoredMessageHistory": "Usuń całą historię wiadomości", + "messageFontSize": "Rozmiar czcionki wiadomości", + "rotateMapWithHeading": "Obracaj mapę z kierunkiem", + "showMapDebugInfo": "Pokaż info debugowania mapy", + "openMapInFullscreen": "Otwórz mapę na pełnym ekranie", + "showSarMarkersLabel": "Pokaż znaczniki SAR", + "hideRepeatersOnMap": "Ukryj przekaźniki na mapie", + "setMapScale": "Ustaw skalę mapy", + "customMapScaleSaved": "Niestandardowa skala mapy zapisana", + "voiceBitrate": "Bitrate głosu", + "voiceCompressor": "Kompresor głosu", + "voiceLimiter": "Limiter głosu", + "micAutoGain": "Automatyczne wzmocnienie mikrofonu", + "echoCancellation": "Usuwanie echa", + "noiseSuppression": "Tłumienie szumu", + "trimSilenceInVoiceMessages": "Przytnij ciszę w wiadomościach głosowych", + "compressor": "Kompresor", + "limiter": "Limiter", + "autoGain": "Automatyczne wzmocnienie", + "echoCancel": "Echo", + "noiseSuppress": "Szum", + "silenceTrim": "Cisza", + "maxImageSize": "Maksymalny rozmiar obrazu", + "imageCompression": "Kompresja obrazu", + "grayscale": "Skala szarości", + "ultraMode": "Tryb ultra", + "fastPrivateGpsUpdates": "Szybkie prywatne aktualizacje GPS", + "movementThreshold": "Próg ruchu", + "fastGpsMovementThreshold": "Próg ruchu szybkiego GPS", + "fastGpsActiveuseInterval": "Interwał aktywnego użycia szybkiego GPS", + "activeuseUpdateInterval": "Interwał aktualizacji przy aktywnym użyciu", + "repeatNearbyTraffic": "Powtarzaj pobliski ruch", + "relayThroughRepeatersAcrossTheMesh": "Przekazuj przez przekaźniki w sieci", + "nearbyOnlyWithoutRepeaterFlooding": "Tylko w pobliżu, bez zalewania przekaźników", + "multihop": "Wieloskokowy", + "createProfile": "Utwórz profil", + "renameProfile": "Zmień nazwę profilu", + "newProfile": "Nowy profil", + "manageProfiles": "Zarządzaj profilami", + "enableProfilesToStartManagingThem": "Włącz profile, aby zacząć nimi zarządzać.", + "openMessage": "Otwórz wiadomość", + "jumpToTheRelatedSarMessage": "Przejdź do powiązanej wiadomości SAR", + "removeSarMarker": "Usuń znacznik SAR", + "pleaseSelectADestinationToSendSarMarker": "Wybierz cel wysłania znacznika SAR", + "sarMarkerBroadcastToPublicChannel": "Znacznik SAR wysłany na kanał publiczny", + "sarMarkerSentToRoom": "Znacznik SAR wysłany do pokoju", + "loadFromGallery": "Załaduj z galerii", + "replaceImage": "Zamień obraz", + "selectFromGallery": "Wybierz z galerii", + "team": "Zespół", + "found": "Znaleziono", + "staging": "Punkt zbiórki", + "object": "Obiekt", + "quiet": "Cicho", + "busy": "Zajęty", + "searchMessages": "Szukaj wiadomości", + "sendImageFromGallery": "Wyślij obraz z galerii", + "takePhoto": "Zrób zdjęcie", + "allMessages": "Wszystkie wiadomości", + "sendToPublicChannel": "Wysłać na kanał publiczny?", + "selectMarkerTypeAndDestination": "Wybierz typ znacznika i cel", + "noDestinationsAvailableLabel": "Brak dostępnych celów", + "image": "Obraz", + "format": "Format", + "dimensions": "Wymiary", + "segments": "Segmenty", + "transfers": "Transfery", + "downloadedBy": "Pobrane przez", + "saveDiscoverySettings": "Zapisz ustawienia wykrywania", + "savePublicInfo": "Zapisz informacje publiczne", + "saveRadioSettings": "Zapisz ustawienia radia", + "savePath": "Zapisz ścieżkę", + "wipeDeviceData": "Wyczyść dane urządzenia", + "wipeDevice": "Wyczyść urządzenie", + "destructiveDeviceActions": "Destrukcyjne akcje na urządzeniu.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Wybierz ustawienie wstępne lub dostosuj ustawienia radia.", + "used": "Użyto", + "total": "Razem", + "renameValue": "Zmień nazwę wartości", + "customizeFields": "Dostosuj pola", + "livePreview": "Podgląd na żywo", + "refreshSchedule": "Harmonogram odświeżania", + "noResponse": "Brak odpowiedzi", + "refreshing": "Odświeżanie", + "unavailable": "Niedostępny", + "pickARelayOrNodeToWatchInSensors": "Wybierz przekaźnik lub węzeł do obserwacji.", + "publicKeyLabel": "Klucz publiczny", + "alreadyInContacts": "Już w kontaktach", + "connectToADeviceBeforeAddingContacts": "Połącz się z urządzeniem przed dodaniem kontaktów", + "fromContacts": "Z kontaktów", + "onlineOnly": "Tylko online", + "inBoth": "W obu", + "source": "Źródło", + "allMessagesCleared": "Wszystkie wiadomości wyczyszczone", + "onlineTraceDatabaseCleared": "Baza śladów wyczyszczona", + "packetLogsCleared": "Logi pakietów wyczyszczone", + "hexDataCopiedToClipboard": "Dane hex skopiowane do schowka", + "developerModeEnabled": "Tryb programisty włączony", + "developerModeDisabled": "Tryb programisty wyłączony", + "clipboardIsEmpty": "Schowek jest pusty", + "contactImported": "Kontakt zaimportowany", + "contactLinkCopiedToClipboard": "Link kontaktu skopiowany do schowka", + "failedToExportContact": "Nie udało się wyeksportować kontaktu", + "noLogsToExport": "Brak logów do eksportu", + "exportAsCsv": "Eksportuj jako CSV", + "exportAsText": "Eksportuj jako tekst", + "receivedRfc3339": "Odebrano (RFC3339)", + "buildTime": "Czas budowania", + "downloadUrlNotAvailable": "URL pobierania niedostępny", + "cannotOpenDownloadUrl": "Nie można otworzyć URL pobierania", + "updateCheckIsOnlyAvailableOnAndroid": "Sprawdzanie aktualizacji dostępne tylko na Androidzie", + "youAreRunningTheLatestVersion": "Używasz najnowszej wersji", + "updateAvailableButDownloadUrlNotFound": "Aktualizacja dostępna, ale URL pobierania nie znaleziony", + "startTictactoe": "Rozpocznij Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe niedostępny", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: przeciwnik nieznany", + "tictactoeWaitingForStart": "Tic-Tac-Toe: oczekiwanie na start", + "acceptsShareLinks": "Akceptuje udostępnione linki", + "supportsRawHex": "Obsługuje surowy hex", + "clipboardfriendly": "Przyjazne dla schowka", + "captured": "Przechwycone", + "size": "Rozmiar", + "noCustomChannelsToClear": "Brak niestandardowych kanałów do wyczyszczenia.", + "noDeviceContactsToClear": "Brak kontaktów urządzenia do wyczyszczenia.", + "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", + "simpleMode": "Tryb prosty", + "simpleModeDescription": "Tylko czat i mapa z ulubionymi kontaktami", + "simpleModeRequiresLockedDestination": "Aby użyć trybu prostego, najpierw włącz „Zablokuj cel” z kanałem", + "noFavouriteContacts": "Brak ulubionych kontaktów", + "selectDestinationFirst": "Najpierw wybierz kanał, kontakt lub pokój", + "recipientNoLongerAvailable": "Odbiorca nie jest już dostępny", + "failedToSendMessage": "Nie udało się wysłać: {error}", + "tictactoeDirectOnly": "Kółko i krzyżyk działa tylko w wiadomościach bezpośrednich. Najpierw wybierz kontakt.", + "deviceKeyUnavailable": "Klucz urządzenia jest niedostępny", + "imageCompressionFailed": "Kompresja obrazu nie powiodła się", + "imageFragmentationFailed": "Fragmentacja obrazu nie powiodła się", + "failedToAnnounceImage": "Nie udało się ogłosić obrazu", + "noRecipientSelected": "Nie wybrano odbiorcy", + "imageSendFailed": "Wysyłanie obrazu nie powiodło się", + "microphonePermissionRequiredForVoice": "Wiadomości głosowe wymagają uprawnienia do mikrofonu", + "recordingDiscarded": "Nagranie odrzucone", + "publicChannelMediaWarning": "Za chwilę wyślesz {mediaType} na kanał publiczny. Nie jest to zalecane, ponieważ może to odebrać każdy w sieci mesh. Wybierz kanał prywatny lub oznaczony, chyba że właśnie tego chcesz.", + "selectPrivateChannelToShareLocation": "Wybierz prywatny kanał, aby udostępnić swoją lokalizację.", + "stopSharingMyLocation": "Przestań udostępniać moją lokalizację", + "shareMyLocation": "Udostępnij moją lokalizację", + "stopRecording": "Zatrzymaj nagrywanie", + "recordVoice": "Nagraj wiadomość głosową", + "moreActions": "Więcej działań", + "noMessagesInFilter": "Brak wiadomości w tym filtrze", + "noMatchesInFilter": "Brak dopasowań w tym filtrze", + "syncFailed": "Synchronizacja nie powiodła się: {error}", + "invalidLink": "Nieprawidłowy link", + "cannotOpenLink": "Nie można otworzyć linku", + "failedToOpenLink": "Nie udało się otworzyć linku", + "failedToResendMessage": "Nie udało się ponownie wysłać wiadomości", + "retryFailed": "Ponowna próba nie powiodła się: {error}", + "deleteDrawingAndMessage": "Usuń rysunek i wiadomość", + "lastEchoRelay": "Ostatnie echo – przekaźnik", + "lastEchoPath": "Ostatnie echo – ścieżka", + "lastEchoBytesReport": "Ostatnie echo – raport bajtów", + "noGpsCoordinatesAvailable": "Brak dostępnych współrzędnych GPS", + "notASarMarker": "To nie jest znacznik SAR", + "failedToSaveTemplate": "Nie udało się zapisać szablonu: {error}", + "drawingNotFound": "Nie znaleziono rysunku", + "unknownDrawingType": "Nieznany typ rysunku", + "customMapMarker": "Niestandardowy znacznik mapy", + "customMapPointLabel": "Punkt: {point}", + "mapIdLabel": "ID mapy: {id}", + "cannotFetchVoice": "Nie można pobrać wiadomości głosowej", + "senderContactUnknown": "Kontakt nadawcy jest nieznany. Najpierw zsynchronizuj kontakty.", + "senderRouteUnknown": "Trasa nadawcy jest nieznana. Najpierw zsynchronizuj kontakty/ścieżkę.", + "messageTooFar": "Wiadomość jest zbyt daleko ({hops} skoków, maks. {maxHops}).", + "senderRouteNoPathResponse": "Trasa nadawcy nie odpowiedziała na sprawdzenie ścieżki. Zsynchronizuj kontakty/ścieżkę i spróbuj ponownie.", + "senderRouteNoRawResponse": "Trasa nadawcy nie odpowiedziała na surowej ścieżce transportowej.", + "voiceFetchOverHops": "Pobieranie głosu przez {hops} skoków może chwilę potrwać.", + "voiceReceiveCanceled": "Odbieranie głosu anulowane", + "fetchingMissingVoiceFragments": "Pobieranie brakujących fragmentów głosu", + "receivingVoice": "Odbieranie głosu", + "cannotFetchImage": "Nie można pobrać obrazu", + "imageFetchOverHops": "Pobieranie obrazu przez {hops} skoków może chwilę potrwać.", + "imageAlreadyBeingReceived": "Obraz jest już odbierany", + "loadImage": "Wczytaj obraz", + "imageFetchFailedToSendRequest": "Nie udało się wysłać żądania pobrania obrazu", + "imageUnavailable": "Obraz chwilowo niedostępny", + "imageFetchTimedOut": "Upłynął limit czasu pobierania obrazu", + "imageReceiveCanceled": "Odbieranie obrazu anulowane", + "closeImagePreview": "Zamknij podgląd obrazu", + "recordingReleaseToSend": "Nagrywanie... puść, aby wysłać", + "sendingVoice": "Wysyłanie głosu...", + "sendLongPressToRecordVoice": "Wyślij (przytrzymaj, aby nagrać głos)", + "discardRecording": "Odrzuć nagranie", + "hopCount": "{count, plural, one{{count} skok} few{{count} skoki} many{{count} skoków} other{{count} skoku}}", + "channelsSectionDescription": "Kanały rozgłoszeniowe dla pobliskiego ruchu mesh", + "roomsSectionDescription": "Wspólne przestrzenie do bieżącej koordynacji zespołu", + "contactsSectionDescription": "Osoby i urządzenia w bezpośrednim zasięgu", + "noRecentChatters": "Brak ostatnich rozmówców", + "public": "Publiczny", + "failedToSendTicTacToeMove": "Nie udało się wysłać ruchu w kółko i krzyżyk", + "youWon": "Wygrałeś", + "opponentWon": "Wygrał przeciwnik", + "gameDraw": "Remis", + "yourTurn": "Twoja kolej", + "opponentTurn": "Kolej przeciwnika", + "pleaseEnterValidCoordinates": "Wprowadź prawidłowe współrzędne", + "locationNotAvailable": "Lokalizacja niedostępna", + "customCaveMapPoint": "Niestandardowy punkt mapy jaskini", + "mapPoint": "Punkt mapy", + "fetchingMissingImageFragments": "Pobieranie brakujących fragmentów…", + "loadingImage": "Wczytywanie…", + "receivingImage": "Odbieranie…", + "tapToLoad": "Dotknij, aby wczytać", + "connectDevice": "Połącz urządzenie", + "chooseTransportSubtitle": "Wybierz transport: Bluetooth, WiFi lub port szeregowy", + "ble": "BLE", + "network": "Sieć", + "serial": "Port szeregowy", + "scan": "Skanuj", + "pressScanToSearchForDevices": "Naciśnij Skanuj, aby wyszukać pobliskie urządzenia", + "failedToConnectToDevice": "Nie udało się połączyć z {name}", + "unknownDevice": "Nieznane urządzenie", + "showingCachedResultsTapRefresh": "Wyświetlane są wyniki z pamięci podręcznej. Dotknij odświeżania, aby zeskanować ponownie.", + "scanningLocalNetworkOnPort": "Skanowanie sieci lokalnej w poszukiwaniu urządzeń MeshCore WiFi na porcie {port}", + "cancelScanAndAddServer": "Anuluj skanowanie i dodaj serwer", + "addServer": "Dodaj serwer", + "scanningProgressIps": "Skanowanie... {scanned}/{total} adresów IP", + "cancelAndEnterManually": "Anuluj i wprowadź ręcznie", + "noRecentOrDiscoveredServers": "Brak ostatnich ani wykrytych serwerów", + "recentlyUsed": "Ostatnio używane", + "connecting": "Łączenie...", + "discoveredOnThisNetwork": "Wykryte w tej sieci", + "serverNoLongerAvailable": "Serwer {host}:{port} nie jest już dostępny. Zeskanuj ponownie, aby znaleźć aktywne serwery.", + "failedToConnectToHost": "Nie udało się połączyć z {host}:{port}", + "enterValidIpAddress": "Wprowadź prawidłowy adres IP", + "enterValidTcpPort": "Wprowadź prawidłowy port TCP", + "ipAddress": "Adres IP", + "tcpPort": "Port TCP", + "customServerPort": "Niestandardowy port serwera", + "chat": "Czat", + "restoringPreviousLink": "Przywracanie poprzedniego połączenia", + "noDeviceConnected": "Brak połączonego urządzenia", + "sendAdvert": "Wyślij mój kontakt", + "advertMode": "Wyślij mój kontakt", + "advertModeSubtitle": "Wybierz, jak daleko mają dotrzeć Twoje dane kontaktowe.", + "floodAdvertSent": "Kontakt wysłano do całej sieci mesh", + "directAdvertSent": "Kontakt wysłano do urządzeń w pobliżu" +} diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb new file mode 100644 index 0000000..b7f2041 --- /dev/null +++ b/lib/l10n/app_pt.arb @@ -0,0 +1,2617 @@ +{ + "@@locale": "pt", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "The application title" + }, + "messages": "Mensagens", + "@messages": { + "description": "Messages tab label" + }, + "contacts": "Contatos", + "@contacts": { + "description": "Contacts tab label" + }, + "map": "Mapa", + "@map": { + "description": "Map tab label" + }, + "settings": "Configurações", + "@settings": { + "description": "Settings screen title" + }, + "connect": "Conectar", + "@connect": { + "description": "Connect button label" + }, + "disconnect": "Desconectar", + "@disconnect": { + "description": "Disconnect button label" + }, + "noDevicesFound": "Nenhum dispositivo encontrado", + "@noDevicesFound": { + "description": "Text shown when no BLE devices are found" + }, + "scanAgain": "Escanear novamente", + "@scanAgain": { + "description": "Button to restart BLE scanning" + }, + "deviceNotConnected": "Dispositivo não conectado", + "@deviceNotConnected": { + "description": "Error message when device is not connected" + }, + "locationPermissionDenied": "Permissão de localização negada", + "@locationPermissionDenied": { + "description": "Error when location permission is denied" + }, + "locationPermissionPermanentlyDenied": "Permissão de localização negada permanentemente. Ative-a em Configurações.", + "@locationPermissionPermanentlyDenied": { + "description": "Error when location permission is permanently denied" + }, + "locationPermissionRequired": "A permissão de localização é necessária para rastreamento GPS e coordenação da equipe. Você pode ativá-la depois em Configurações.", + "@locationPermissionRequired": { + "description": "Message when location permission is needed" + }, + "locationServicesDisabled": "Os serviços de localização estão desativados. Ative-os em Configurações.", + "@locationServicesDisabled": { + "description": "Error when location services are disabled" + }, + "failedToGetGpsLocation": "Falha ao obter localização GPS", + "@failedToGetGpsLocation": { + "description": "Error when GPS location cannot be obtained" + }, + "failedToAdvertise": "Falha ao anunciar: {error}", + "@failedToAdvertise": { + "description": "Error message for failed advertisement", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "Cancelar reconexão", + "@cancelReconnection": { + "description": "Tooltip for cancel reconnection button" + }, + "general": "Geral", + "@general": { + "description": "General settings section header" + }, + "theme": "Tema", + "@theme": { + "description": "Theme setting label" + }, + "chooseTheme": "Escolher tema", + "@chooseTheme": { + "description": "Theme selection dialog title" + }, + "light": "Claro", + "@light": { + "description": "Light theme option" + }, + "dark": "Escuro", + "@dark": { + "description": "Dark theme option" + }, + "blueLightTheme": "Tema azul claro", + "@blueLightTheme": { + "description": "Description for blue light theme" + }, + "blueDarkTheme": "Tema azul escuro", + "@blueDarkTheme": { + "description": "Description for blue dark theme" + }, + "sarRed": "SAR Vermelho", + "@sarRed": { + "description": "SAR Red theme option" + }, + "alertEmergencyMode": "Modo de alerta/emergência", + "@alertEmergencyMode": { + "description": "Description for SAR Red theme" + }, + "sarGreen": "SAR Verde", + "@sarGreen": { + "description": "SAR Green theme option" + }, + "safeAllClearMode": "Modo seguro/tudo limpo", + "@safeAllClearMode": { + "description": "Description for SAR Green theme" + }, + "autoSystem": "Automático (Sistema)", + "@autoSystem": { + "description": "Auto/System theme option" + }, + "followSystemTheme": "Seguir tema do sistema", + "@followSystemTheme": { + "description": "Description for system theme" + }, + "showRxTxIndicators": "Mostrar indicadores RX/TX", + "@showRxTxIndicators": { + "description": "Setting to show RX/TX indicators" + }, + "disableMap": "Desativar mapa", + "@disableMap": { + "description": "Setting to disable the map tab" + }, + "language": "Idioma", + "@language": { + "description": "Language setting label" + }, + "chooseLanguage": "Escolher idioma", + "@chooseLanguage": { + "description": "Language selection dialog title" + }, + "save": "Salvar", + "@save": { + "description": "Save button label" + }, + "cancel": "Cancelar", + "@cancel": { + "description": "Cancel button label" + }, + "close": "Fechar", + "@close": { + "description": "Close button label" + }, + "about": "Sobre", + "@about": { + "description": "About section header" + }, + "appVersion": "Versão do app", + "@appVersion": { + "description": "App version label" + }, + "aboutMeshCoreSar": "Sobre o MeshCore SAR", + "@aboutMeshCoreSar": { + "description": "About dialog title" + }, + "aboutDescription": "Um aplicativo de Busca e Resgate projetado para equipes de resposta a emergências. Os recursos incluem:\n\n• Rede mesh BLE para comunicação entre dispositivos\n• Mapas offline com múltiplas opções de camadas\n• Rastreamento em tempo real dos membros da equipe\n• Marcadores táticos SAR (pessoa encontrada, incêndio, área de apoio)\n• Gerenciamento de contatos e mensagens\n• Rastreamento GPS com rumo da bússola\n• Cache de blocos de mapa para uso offline", + "@aboutDescription": { + "description": "About dialog description" + }, + "technologiesUsed": "Tecnologias utilizadas:", + "@technologiesUsed": { + "description": "Technologies used section title" + }, + "technologiesList": "• Flutter para desenvolvimento multiplataforma\n• BLE (Bluetooth Low Energy) para rede mesh\n• OpenStreetMap para mapas\n• Provider para gerenciamento de estado\n• SharedPreferences para armazenamento local", + "@technologiesList": { + "description": "List of technologies used" + }, + "moreInfo": "Mais informações", + "@moreInfo": { + "description": "More info button label" + }, + "packageName": "Nome do pacote", + "@packageName": { + "description": "Package name label" + }, + "sampleData": "Dados de exemplo", + "@sampleData": { + "description": "Sample data section header" + }, + "sampleDataDescription": "Carregar ou limpar contatos, mensagens de canal e marcadores SAR de exemplo para testes", + "@sampleDataDescription": { + "description": "Sample data section description" + }, + "loadSampleData": "Carregar dados de exemplo", + "@loadSampleData": { + "description": "Load sample data button" + }, + "clearAllData": "Limpar todos os dados", + "@clearAllData": { + "description": "Clear all data button" + }, + "clearAllDataConfirmTitle": "Limpar todos os dados", + "@clearAllDataConfirmTitle": { + "description": "Clear data confirmation dialog title" + }, + "clearAllDataConfirmMessage": "Isso limpará todos os contatos e marcadores SAR. Tem certeza?", + "@clearAllDataConfirmMessage": { + "description": "Clear data confirmation message" + }, + "clear": "Limpar", + "@clear": { + "description": "Clear button label" + }, + "loadedSampleData": "Carregados {teamCount} membros da equipe, {channelCount} canais, {sarCount} marcadores SAR, {messageCount} mensagens", + "@loadedSampleData": { + "description": "Success message after loading sample data", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "Falha ao carregar dados de exemplo: {error}", + "@failedToLoadSampleData": { + "description": "Error message when sample data fails to load", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "Todos os dados foram limpos", + "@allDataCleared": { + "description": "Success message after clearing all data" + }, + "failedToStartBackgroundTracking": "Falha ao iniciar rastreamento em segundo plano. Verifique permissões e conexão BLE.", + "@failedToStartBackgroundTracking": { + "description": "Error message when background tracking fails to start" + }, + "locationBroadcast": "Transmissão de localização: {latitude}, {longitude}", + "@locationBroadcast": { + "description": "Success message for location broadcast", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "O PIN padrão para dispositivos sem tela é 123456. Problemas ao parear? Esqueça o dispositivo Bluetooth nas configurações do sistema.", + "@defaultPinInfo": { + "description": "Information about default PIN for pairing" + }, + "noMessagesYet": "Ainda não há mensagens", + "@noMessagesYet": { + "description": "Empty state message when there are no messages" + }, + "pullDownToSync": "Puxe para baixo para sincronizar mensagens", + "@pullDownToSync": { + "description": "Instruction to pull down to refresh messages" + }, + "deleteContact": "Excluir contato", + "@deleteContact": { + "description": "Delete contact action label" + }, + "delete": "Excluir", + "@delete": { + "description": "Delete button label" + }, + "viewOnMap": "Ver no mapa", + "@viewOnMap": { + "description": "Action to view contact location on map" + }, + "refresh": "Atualizar", + "@refresh": { + "description": "Refresh button label" + }, + "resetPath": "Redefinir rota", + "@resetPath": { + "description": "Action to reset contact path for re-routing" + }, + "copiedToClipboard": "{label} copiado para a área de transferência", + "@copiedToClipboard": { + "description": "Success message when a value is copied to clipboard", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "Digite uma senha", + "@pleaseEnterPassword": { + "description": "Validation message for empty password field" + }, + "failedToSyncContacts": "Falha ao sincronizar contatos: {error}", + "@failedToSyncContacts": { + "description": "Error message when contact sync fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "Login realizado com sucesso! Aguardando mensagens da sala...", + "@loggedInSuccessfully": { + "description": "Success message after successful room login" + }, + "loginFailed": "Falha no login - senha incorreta", + "@loginFailed": { + "description": "Error message when room login fails" + }, + "loggingIn": "Entrando em {roomName}...", + "@loggingIn": { + "description": "Status message during room login process", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "Falha ao enviar login: {error}", + "@failedToSendLogin": { + "description": "Error message when login command fails to send", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "Baixa precisão de localização", + "@lowLocationAccuracy": { + "description": "Warning title for low GPS accuracy" + }, + "continue_": "Continuar", + "@continue_": { + "description": "Continue button label" + }, + "sendSarMarker": "Enviar marcador SAR", + "@sendSarMarker": { + "description": "Action to send SAR marker" + }, + "deleteDrawing": "Excluir desenho", + "@deleteDrawing": { + "description": "Action to delete a map drawing" + }, + "drawingTools": "Ferramentas de desenho", + "@drawingTools": { + "description": "Drawing tools section or menu title" + }, + "drawLine": "Desenhar linha", + "@drawLine": { + "description": "Map drawing mode: line" + }, + "drawLineDesc": "Desenhar uma linha livre no mapa", + "@drawLineDesc": { + "description": "Description for line drawing mode" + }, + "drawRectangle": "Desenhar retângulo", + "@drawRectangle": { + "description": "Map drawing mode: rectangle" + }, + "drawRectangleDesc": "Desenhar uma área retangular no mapa", + "@drawRectangleDesc": { + "description": "Description for rectangle drawing mode" + }, + "measureDistance": "Medir distância", + "@measureDistance": { + "description": "Map drawing mode: measure distance" + }, + "measureDistanceDesc": "Pressione longamente dois pontos para medir", + "@measureDistanceDesc": { + "description": "Description for distance measurement mode" + }, + "clearMeasurement": "Limpar medição", + "@clearMeasurement": { + "description": "Tooltip to clear measurement" + }, + "distanceLabel": "Distância: {distance}", + "@distanceLabel": { + "description": "Label showing measured distance", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "Pressione longamente para o segundo ponto", + "@longPressForSecondPoint": { + "description": "Instruction when first measurement point is set" + }, + "longPressToStartMeasurement": "Pressione longamente para definir o primeiro ponto", + "@longPressToStartMeasurement": { + "description": "Instruction to start measurement" + }, + "longPressToStartNewMeasurement": "Pressione e segure para iniciar uma nova medição", + "@longPressToStartNewMeasurement": { + "description": "Instruction to restart measurement after completion" + }, + "shareDrawings": "Compartilhar desenhos", + "@shareDrawings": { + "description": "Action to share drawings to network" + }, + "clearAllDrawings": "Limpar todos os desenhos", + "@clearAllDrawings": { + "description": "Action to clear all local drawings" + }, + "completeLine": "Concluir linha", + "@completeLine": { + "description": "Tooltip to complete drawing a line" + }, + "broadcastDrawingsToTeam": "Transmitir {count} desenho{plural} para a equipe", + "@broadcastDrawingsToTeam": { + "description": "Subtitle showing how many drawings will be broadcast", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "Remover todos os {count} desenho{plural}", + "@removeAllDrawings": { + "description": "Subtitle for remove all drawings action", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "Excluir todos os {count} desenho{plural} do mapa?", + "@deleteAllDrawingsConfirm": { + "description": "Confirmation dialog message for deleting all drawings", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "Desenho", + "@drawing": { + "description": "Generic drawing label" + }, + "shareDrawingsCount": "Compartilhar {count} desenho{plural}", + "@shareDrawingsCount": { + "description": "Title for share drawings dialog", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "Mostrar desenhos recebidos", + "@showReceivedDrawings": { + "description": "Toggle to show/hide received drawings from other team members" + }, + "showingAllDrawings": "Mostrando todos os desenhos", + "@showingAllDrawings": { + "description": "Subtitle when received drawings are visible" + }, + "showingOnlyYourDrawings": "Mostrando apenas seus desenhos", + "@showingOnlyYourDrawings": { + "description": "Subtitle when received drawings are hidden" + }, + "showSarMarkers": "Mostrar marcadores SAR", + "@showSarMarkers": { + "description": "Toggle to show/hide SAR markers on map" + }, + "showingSarMarkers": "Mostrando marcadores SAR", + "@showingSarMarkers": { + "description": "Subtitle when SAR markers are visible" + }, + "hidingSarMarkers": "Ocultando marcadores SAR", + "@hidingSarMarkers": { + "description": "Subtitle when SAR markers are hidden" + }, + "clearAll": "Limpar tudo", + "@clearAll": { + "description": "Clear all button label" + }, + "publicChannel": "Canal público", + "@publicChannel": { + "description": "Public channel option for sharing" + }, + "broadcastToAll": "Transmitir para todos os nós próximos", + "@broadcastToAll": { + "description": "Description for public channel broadcast" + }, + "storedPermanently": "Armazenado permanentemente na sala", + "@storedPermanently": { + "description": "Description for room storage permanence" + }, + "notConnectedToDevice": "Não conectado ao dispositivo", + "@notConnectedToDevice": { + "description": "Error message when device is not connected for direct messaging" + }, + "typeYourMessage": "Digite sua mensagem...", + "@typeYourMessage": { + "description": "Placeholder text for message input field" + }, + "quickLocationMarker": "Marcador rápido de localização", + "@quickLocationMarker": { + "description": "Subtitle for SAR marker sheet header" + }, + "markerType": "Tipo de marcador", + "@markerType": { + "description": "Label for marker type selection section" + }, + "sendTo": "Enviar para", + "@sendTo": { + "description": "Label for destination selection section" + }, + "noDestinationsAvailable": "Nenhum destino disponível.", + "@noDestinationsAvailable": { + "description": "Warning when no rooms or channels exist" + }, + "selectDestination": "Selecione o destino...", + "@selectDestination": { + "description": "Placeholder for destination dropdown" + }, + "ephemeralBroadcastInfo": "Efêmero: transmitido apenas pelo ar. Não é armazenado; os nós precisam estar online.", + "@ephemeralBroadcastInfo": { + "description": "Information about ephemeral channel broadcasts" + }, + "persistentRoomInfo": "Persistente: armazenado de forma imutável na sala. Sincronizado automaticamente e preservado offline.", + "@persistentRoomInfo": { + "description": "Information about persistent room storage" + }, + "location": "Localização", + "@location": { + "description": "Label for location section" + }, + "fromMap": "Do mapa", + "@fromMap": { + "description": "Badge showing location is from map tap" + }, + "gettingLocation": "Obtendo localização...", + "@gettingLocation": { + "description": "Loading message while fetching GPS location" + }, + "locationError": "Erro de localização", + "@locationError": { + "description": "Title for location error messages" + }, + "retry": "Tentar novamente", + "@retry": { + "description": "Retry button label" + }, + "refreshLocation": "Atualizar localização", + "@refreshLocation": { + "description": "Tooltip for refresh location button" + }, + "accuracyMeters": "Precisão: ±{accuracy}m", + "@accuracyMeters": { + "description": "Display of GPS accuracy in meters", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "Notas (opcional)", + "@notesOptional": { + "description": "Label for optional notes field" + }, + "addAdditionalInformation": "Adicionar informações extras...", + "@addAdditionalInformation": { + "description": "Placeholder for notes field" + }, + "lowAccuracyWarning": "A precisão da localização é ±{accuracy}m. Isso pode não ser preciso o suficiente para operações SAR.\n\nDeseja continuar mesmo assim?", + "@lowAccuracyWarning": { + "description": "Warning dialog content for low GPS accuracy", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "Entrar na sala", + "@loginToRoom": { + "description": "Title for room login dialog" + }, + "enterPasswordInfo": "Digite a senha para acessar esta sala. A senha será salva para uso futuro.", + "@enterPasswordInfo": { + "description": "Information about room password" + }, + "password": "Senha", + "@password": { + "description": "Password field label" + }, + "enterRoomPassword": "Digite a senha da sala", + "@enterRoomPassword": { + "description": "Password field hint" + }, + "loggingInDots": "Entrando...", + "@loggingInDots": { + "description": "Button text while logging in" + }, + "login": "Entrar", + "@login": { + "description": "Login button label" + }, + "failedToAddRoom": "Falha ao adicionar a sala ao dispositivo: {error}\n\nA sala talvez ainda não tenha sido anunciada.\nTente esperar a sala começar a transmitir.", + "@failedToAddRoom": { + "description": "Error message when adding room fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "Direto", + "@direct": { + "description": "Direct routing indicator" + }, + "flood": "Inundação", + "@flood": { + "description": "Flood routing indicator" + }, + "loggedIn": "Conectado", + "@loggedIn": { + "description": "Logged in status badge" + }, + "noGpsData": "Sem dados GPS", + "@noGpsData": { + "description": "Message when GPS data is not available" + }, + "distance": "Distância", + "@distance": { + "description": "Distance label" + }, + "directPingTimeout": "Tempo limite do ping direto - tentando novamente {name} com flooding...", + "@directPingTimeout": { + "description": "Warning when direct ping times out", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "Falha no ping para {name} - nenhuma resposta recebida", + "@pingFailed": { + "description": "Error message when ping fails", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "Tem certeza de que deseja excluir \"{name}\"?\n\nIsso removerá o contato tanto do aplicativo quanto do dispositivo de rádio associado.", + "@deleteContactConfirmation": { + "description": "Confirmation message for deleting contact", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "Falha ao remover contato: {error}", + "@failedToRemoveContact": { + "description": "Error message when contact removal fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "Tipo", + "@type": { + "description": "Contact type label" + }, + "publicKey": "Chave pública", + "@publicKey": { + "description": "Public key label" + }, + "lastSeen": "Visto por último", + "@lastSeen": { + "description": "Last seen label" + }, + "yes": "Sim", + "@yes": { + "description": "Yes answer" + }, + "no": "Não", + "@no": { + "description": "No answer" + }, + "permissions": "Permissões", + "@permissions": { + "description": "Permissions label" + }, + "telemetry": "Telemetria", + "@telemetry": { + "description": "Telemetry section header" + }, + "voltage": "Voltagem", + "@voltage": { + "description": "Voltage label" + }, + "battery": "Bateria", + "@battery": { + "description": "Battery label" + }, + "temperature": "Temperatura", + "@temperature": { + "description": "Temperature label" + }, + "humidity": "Umidade", + "@humidity": { + "description": "Humidity label" + }, + "pressure": "Pressão", + "@pressure": { + "description": "Pressure label" + }, + "gpsTelemetry": "GPS (telemetria)", + "@gpsTelemetry": { + "description": "GPS from telemetry label" + }, + "reLoginToRoom": "Entrar novamente na sala", + "@reLoginToRoom": { + "description": "Button to re-login to room" + }, + "heading": "Direção", + "@heading": { + "description": "Compass heading label" + }, + "elevation": "Elevação", + "@elevation": { + "description": "Elevation/altitude label" + }, + "accuracy": "Precisão", + "@accuracy": { + "description": "GPS accuracy label" + }, + "bearing": "Rumo", + "@bearing": { + "description": "Bearing label in compass" + }, + "direction": "Direção", + "@direction": { + "description": "Direction label in compass" + }, + "filterMarkers": "Filtrar marcadores", + "@filterMarkers": { + "description": "Title for filter markers dialog" + }, + "filterMarkersTooltip": "Filtrar marcadores", + "@filterMarkersTooltip": { + "description": "Tooltip for filter button" + }, + "contactsFilter": "Contatos", + "@contactsFilter": { + "description": "Filter option for contacts" + }, + "repeatersFilter": "Repetidores", + "@repeatersFilter": { + "description": "Filter option for repeaters" + }, + "sarMarkers": "Marcadores SAR", + "@sarMarkers": { + "description": "SAR markers section header" + }, + "foundPerson": "Pessoa encontrada", + "@foundPerson": { + "description": "Found person SAR marker type" + }, + "fire": "Incêndio", + "@fire": { + "description": "Fire SAR marker type" + }, + "stagingArea": "Área de apoio", + "@stagingArea": { + "description": "Staging area SAR marker type" + }, + "showAll": "Mostrar tudo", + "@showAll": { + "description": "Button to show all filters" + }, + "locationUnavailable": "Localização indisponível", + "@locationUnavailable": { + "description": "Message when GPS location is unavailable" + }, + "ahead": "à frente", + "@ahead": { + "description": "Relative bearing direction - ahead" + }, + "degreesRight": "{degrees}° à direita", + "@degreesRight": { + "description": "Relative bearing direction - right", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "{degrees}° à esquerda", + "@degreesLeft": { + "description": "Relative bearing direction - left", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "Lat.: {latitude} Lon.: {longitude}", + "@latLonFormat": { + "description": "Latitude and longitude display format", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "Ainda não há contatos", + "@noContactsYet": { + "description": "Empty state message when there are no contacts" + }, + "connectToDeviceToLoadContacts": "Conecte-se a um dispositivo para carregar contatos", + "@connectToDeviceToLoadContacts": { + "description": "Instruction to connect device to load contacts" + }, + "teamMembers": "Membros da equipe", + "@teamMembers": { + "description": "Section header for team members (chat contacts)" + }, + "repeaters": "Repetidores", + "@repeaters": { + "description": "Section header for repeater nodes" + }, + "rooms": "Salas", + "@rooms": { + "description": "Section header for rooms" + }, + "channels": "Canais", + "@channels": { + "description": "Section header for broadcast channels" + }, + "selectMapLayer": "Selecionar camada do mapa", + "@selectMapLayer": { + "description": "Title for map layer selection dialog" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "OpenStreetMap layer name" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "OpenTopoMap layer name" + }, + "esriSatellite": "Satélite ESRI", + "@esriSatellite": { + "description": "ESRI Satellite imagery layer name" + }, + "googleHybrid": "Google Híbrido", + "@googleHybrid": { + "description": "Google Hybrid layer name (satellite + labels)" + }, + "googleRoadmap": "Google Mapa", + "@googleRoadmap": { + "description": "Google Roadmap layer name (street map)" + }, + "googleTerrain": "Google Terreno", + "@googleTerrain": { + "description": "Google Terrain layer name (topographic)" + }, + "dragToPosition": "Arraste para a posição", + "@dragToPosition": { + "description": "Label when dragging a pin on map" + }, + "createSarMarker": "Criar marcador SAR", + "@createSarMarker": { + "description": "Label for creating SAR marker from pin" + }, + "compass": "Bússola", + "@compass": { + "description": "Compass title in detailed compass dialog" + }, + "navigationAndContacts": "Navegação e contatos", + "@navigationAndContacts": { + "description": "Subtitle for compass dialog" + }, + "sarAlert": "ALERTA SAR", + "@sarAlert": { + "description": "Label for SAR alert badge on messages" + }, + "textCopiedToClipboard": "Texto copiado para a área de transferência", + "@textCopiedToClipboard": { + "description": "Success message when text is copied" + }, + "cannotReplySenderMissing": "Não é possível responder: faltam informações do remetente", + "@cannotReplySenderMissing": { + "description": "Error when sender info is missing for reply" + }, + "cannotReplyContactNotFound": "Não é possível responder: contato não encontrado", + "@cannotReplyContactNotFound": { + "description": "Error when contact not found for reply" + }, + "copyText": "Copiar texto", + "@copyText": { + "description": "Option to copy message text to clipboard" + }, + "saveAsTemplate": "Salvar como modelo", + "@saveAsTemplate": { + "description": "Option to save SAR message as a reusable template" + }, + "templateSaved": "Modelo salvo com sucesso", + "@templateSaved": { + "description": "Success message when SAR template is saved" + }, + "templateAlreadyExists": "Já existe um modelo com este emoji", + "@templateAlreadyExists": { + "description": "Error message when trying to save duplicate template" + }, + "deleteMessage": "Excluir mensagem", + "@deleteMessage": { + "description": "Dialog title for deleting a message" + }, + "deleteMessageConfirmation": "Tem certeza de que deseja excluir esta mensagem?", + "@deleteMessageConfirmation": { + "description": "Confirmation text for message deletion" + }, + "shareLocation": "Compartilhar localização", + "@shareLocation": { + "description": "Option to share SAR marker location" + }, + "shareLocationText": "{markerInfo}\n\nCoordenadas: {lat}, {lon}\n\nGoogle Maps: {url}", + "@shareLocationText": { + "description": "Formatted text for sharing SAR marker location", + "placeholders": { + "markerInfo": { + "type": "String" + }, + "lat": { + "type": "String" + }, + "lon": { + "type": "String" + }, + "url": { + "type": "String" + } + } + }, + "sarLocationShare": "Localização SAR", + "@sarLocationShare": { + "description": "Subject line when sharing SAR marker location" + }, + "justNow": "Agora mesmo", + "@justNow": { + "description": "Time indicator for very recent activity" + }, + "minutesAgo": "há {minutes} min", + "@minutesAgo": { + "description": "Time indicator for minutes ago", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "há {hours} h", + "@hoursAgo": { + "description": "Time indicator for hours ago", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "há {days} d", + "@daysAgo": { + "description": "Time indicator for days ago", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "há {seconds} s", + "@secondsAgo": { + "description": "Time indicator for seconds ago", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "Enviando...", + "@sending": { + "description": "Delivery status: sending" + }, + "sent": "Enviado", + "@sent": { + "description": "Delivery status: sent" + }, + "delivered": "Entregue", + "@delivered": { + "description": "Delivery status: delivered" + }, + "deliveredWithTime": "Entregue ({time}ms)", + "@deliveredWithTime": { + "description": "Delivery status with round-trip time", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "Falhou", + "@failed": { + "description": "Delivery status: failed" + }, + "broadcast": "Transmitir", + "@broadcast": { + "description": "Delivery status for channel messages (no echoes yet)" + }, + "deliveredToContacts": "Entregue a {delivered}/{total} contatos", + "@deliveredToContacts": { + "description": "Grouped message delivery count", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "Todos entregues", + "@allDelivered": { + "description": "Status when all recipients received the message" + }, + "recipientDetails": "Detalhes do destinatário", + "@recipientDetails": { + "description": "Header for expandable recipient list" + }, + "pending": "Pendente", + "@pending": { + "description": "Delivery status: pending/waiting" + }, + "sarMarkerFoundPerson": "Pessoa encontrada", + "@sarMarkerFoundPerson": { + "description": "SAR marker type: found person" + }, + "sarMarkerFire": "Local do incêndio", + "@sarMarkerFire": { + "description": "SAR marker type: fire" + }, + "sarMarkerStagingArea": "Área de apoio", + "@sarMarkerStagingArea": { + "description": "SAR marker type: staging area" + }, + "sarMarkerObject": "Objeto encontrado", + "@sarMarkerObject": { + "description": "SAR marker type: object" + }, + "from": "De", + "@from": { + "description": "Sender label in notifications" + }, + "coordinates": "Coordenadas", + "@coordinates": { + "description": "Coordinates label" + }, + "tapToViewOnMap": "Toque para ver no mapa", + "@tapToViewOnMap": { + "description": "Notification action text" + }, + "radioSettings": "Configurações do rádio", + "@radioSettings": { + "description": "Section title for radio settings" + }, + "frequencyMHz": "Frequência (MHz)", + "@frequencyMHz": { + "description": "Label for radio frequency field" + }, + "bandwidth": "Largura de banda", + "@bandwidth": { + "description": "Label for bandwidth dropdown" + }, + "spreadingFactor": "Fator de espalhamento", + "@spreadingFactor": { + "description": "Label for spreading factor dropdown" + }, + "codingRate": "Taxa de codificação", + "@codingRate": { + "description": "Label for coding rate dropdown" + }, + "txPowerDbm": "Potência TX (dBm)", + "@txPowerDbm": { + "description": "Label for TX power field" + }, + "maxPowerDbm": "Máx: {power} dBm", + "@maxPowerDbm": { + "description": "Helper text showing maximum TX power", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "Você", + "@you": { + "description": "Label for the current user in message bubbles" + }, + "exportFailed": "Falha na exportação: {error}", + "@exportFailed": { + "description": "Error message when export fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "Falha na importação: {error}", + "@importFailed": { + "description": "Error message when import fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "Desconhecido", + "@unknown": { + "description": "Unknown value label" + }, + "onlineLayers": "Camadas online", + "@onlineLayers": { + "description": "Section header for online map layers" + }, + "locationTrail": "Trilha de localização", + "@locationTrail": { + "description": "Location trail title" + }, + "showTrailOnMap": "Mostrar trilha no mapa", + "@showTrailOnMap": { + "description": "Toggle to show/hide trail on map" + }, + "trailVisible": "A trilha está visível no mapa", + "@trailVisible": { + "description": "Trail visibility status - visible" + }, + "trailHiddenRecording": "A trilha está oculta (ainda gravando)", + "@trailHiddenRecording": { + "description": "Trail visibility status - hidden but recording" + }, + "duration": "Duração", + "@duration": { + "description": "Duration label" + }, + "points": "Pontos", + "@points": { + "description": "Trail points count label" + }, + "clearTrail": "Limpar trilha", + "@clearTrail": { + "description": "Button to clear location trail" + }, + "clearTrailQuestion": "Limpar trilha?", + "@clearTrailQuestion": { + "description": "Confirmation dialog title" + }, + "clearTrailConfirmation": "Tem certeza de que deseja limpar a trilha de localização atual?", + "@clearTrailConfirmation": { + "description": "Confirmation dialog message" + }, + "noTrailRecorded": "Nenhuma trilha gravada ainda", + "@noTrailRecorded": { + "description": "Message when no trail exists" + }, + "startTrackingToRecord": "Inicie o rastreamento de localização para gravar sua trilha", + "@startTrackingToRecord": { + "description": "Instructions to start trail recording" + }, + "trailControls": "Controles da trilha", + "@trailControls": { + "description": "Trail controls tooltip" + }, + "model": "Modelo", + "@model": { + "description": "Device model label" + }, + "version": "Versão", + "@version": { + "description": "Version label" + }, + "maxContacts": "Máx. de contatos", + "@maxContacts": { + "description": "Maximum contacts capacity label" + }, + "maxChannels": "Máx. de canais", + "@maxChannels": { + "description": "Maximum channels capacity label" + }, + "publicInfo": "Informações públicas", + "@publicInfo": { + "description": "Public information section header" + }, + "useCurrentLocation": "Usar localização atual", + "@useCurrentLocation": { + "description": "Tooltip for use current location button" + }, + "noneUnknown": "Nenhum/Desconhecido", + "@noneUnknown": { + "description": "Device type: none or unknown" + }, + "chatNode": "Nó de chat", + "@chatNode": { + "description": "Device type: chat node" + }, + "repeater": "Repetidor", + "@repeater": { + "description": "Device type: repeater" + }, + "roomChannel": "Sala/Canal", + "@roomChannel": { + "description": "Device type: room or channel" + }, + "typeNumber": "Tipo {number}", + "@typeNumber": { + "description": "Generic device type with number", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "Falha ao salvar: {error}", + "@failedToSave": { + "description": "Generic error message for save failures", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "Falha ao obter localização: {error}", + "@failedToGetLocation": { + "description": "Error message when getting location fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "Modelos SAR", + "@sarTemplates": { + "description": "SAR templates menu title" + }, + "addTemplate": "Adicionar modelo", + "@addTemplate": { + "description": "Button to add new SAR template" + }, + "editTemplate": "Editar modelo", + "@editTemplate": { + "description": "Dialog title for editing template" + }, + "deleteTemplate": "Excluir modelo", + "@deleteTemplate": { + "description": "Action to delete template" + }, + "templateName": "Nome do modelo", + "@templateName": { + "description": "Label for template name field" + }, + "templateNameHint": "ex.: Pessoa encontrada", + "@templateNameHint": { + "description": "Hint text for template name" + }, + "templateEmoji": "Emoji", + "@templateEmoji": { + "description": "Label for template emoji field" + }, + "emojiRequired": "Emoji é obrigatório", + "@emojiRequired": { + "description": "Validation error when emoji field is empty" + }, + "nameRequired": "Nome é obrigatório", + "@nameRequired": { + "description": "Validation error when name field is empty" + }, + "templateDescription": "Descrição (opcional)", + "@templateDescription": { + "description": "Label for template description field" + }, + "templateDescriptionHint": "Adicionar contexto extra...", + "@templateDescriptionHint": { + "description": "Hint text for template description" + }, + "templateColor": "Cor", + "@templateColor": { + "description": "Label for template color picker" + }, + "previewFormat": "Pré-visualização (formato de mensagem SAR)", + "@previewFormat": { + "description": "Label for format preview" + }, + "importFromClipboard": "Importar", + "@importFromClipboard": { + "description": "Button to import templates from clipboard" + }, + "exportToClipboard": "Exportar", + "@exportToClipboard": { + "description": "Button to export templates to clipboard" + }, + "deleteTemplateConfirmation": "Excluir o modelo “{name}”?", + "@deleteTemplateConfirmation": { + "description": "Confirmation message for template deletion", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "templateAdded": "Modelo adicionado", + "@templateAdded": { + "description": "Success message when template is added" + }, + "templateUpdated": "Modelo atualizado", + "@templateUpdated": { + "description": "Success message when template is updated" + }, + "templateDeleted": "Modelo excluído", + "@templateDeleted": { + "description": "Success message when template is deleted" + }, + "templatesImported": "{count, plural, =0{Nenhum modelo importado} =1{1 modelo importado} other{{count} modelos importados}}", + "@templatesImported": { + "description": "Success message after importing templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "templatesExported": "{count, plural, =1{1 modelo exportado para a área de transferência} other{{count} modelos exportados para a área de transferência}}", + "@templatesExported": { + "description": "Success message after exporting templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "resetToDefaults": "Restaurar padrão", + "@resetToDefaults": { + "description": "Action to reset templates to defaults" + }, + "resetToDefaultsConfirmation": "Isso excluirá todos os modelos personalizados e restaurará os 4 modelos padrão. Continuar?", + "@resetToDefaultsConfirmation": { + "description": "Confirmation message for reset to defaults" + }, + "reset": "Redefinir", + "@reset": { + "description": "Reset button label" + }, + "resetComplete": "Modelos redefinidos para o padrão", + "@resetComplete": { + "description": "Success message after reset" + }, + "noTemplates": "Nenhum modelo disponível", + "@noTemplates": { + "description": "Message when no templates exist" + }, + "tapAddToCreate": "Toque em + para criar seu primeiro modelo", + "@tapAddToCreate": { + "description": "Helper text when no templates exist" + }, + "ok": "OK", + "@ok": { + "description": "OK button label" + }, + "locationPermission": "Permissão de localização", + "@locationPermission": { + "description": "Location permission label" + }, + "checking": "Verificando...", + "@checking": { + "description": "Loading state indicator" + }, + "locationPermissionGrantedAlways": "Concedida (Sempre)", + "@locationPermissionGrantedAlways": { + "description": "Location permission status: granted always" + }, + "locationPermissionGrantedWhileInUse": "Concedida (Durante o uso)", + "@locationPermissionGrantedWhileInUse": { + "description": "Location permission status: granted while in use" + }, + "locationPermissionDeniedTapToRequest": "Negada - toque para solicitar", + "@locationPermissionDeniedTapToRequest": { + "description": "Location permission status: denied, user can request" + }, + "locationPermissionPermanentlyDeniedOpenSettings": "Negada permanentemente - abrir configurações", + "@locationPermissionPermanentlyDeniedOpenSettings": { + "description": "Location permission status: permanently denied" + }, + "locationPermissionDialogContent": "A permissão de localização foi negada permanentemente. Ative-a nas configurações do dispositivo.", + "@locationPermissionDialogContent": { + "description": "Content for location permission dialog when permanently denied" + }, + "openSettings": "Abrir configurações", + "@openSettings": { + "description": "Button to open device settings" + }, + "locationPermissionGranted": "Permissão de localização concedida!", + "@locationPermissionGranted": { + "description": "Success message when location permission is granted" + }, + "locationPermissionRequiredForGps": "A permissão de localização é necessária para rastreamento GPS e compartilhamento de localização.", + "@locationPermissionRequiredForGps": { + "description": "Info message about location permission requirement" + }, + "locationPermissionAlreadyGranted": "A permissão de localização já foi concedida.", + "@locationPermissionAlreadyGranted": { + "description": "Info message when permission is already granted" + }, + "sarNavyBlue": "SAR Azul Marinho", + "@sarNavyBlue": { + "description": "SAR Navy Blue theme name" + }, + "sarNavyBlueDescription": "Modo profissional/operações", + "@sarNavyBlueDescription": { + "description": "Description for SAR Navy Blue theme" + }, + "selectRecipient": "Selecionar destinatário", + "@selectRecipient": { + "description": "Title for recipient selector sheet" + }, + "broadcastToAllNearby": "Transmitir para todos os próximos", + "@broadcastToAllNearby": { + "description": "Subtitle for public channel option" + }, + "searchRecipients": "Pesquisar destinatários...", + "@searchRecipients": { + "description": "Placeholder text for recipient search field" + }, + "noContactsFound": "Nenhum contato encontrado", + "@noContactsFound": { + "description": "Message when no contacts match search" + }, + "noRoomsFound": "Nenhuma sala encontrada", + "@noRoomsFound": { + "description": "Message when no rooms match search" + }, + "noRecipientsAvailable": "Nenhum destinatário disponível", + "@noRecipientsAvailable": { + "description": "Message when no recipients exist (contacts, rooms, or channels)" + }, + "noChannelsFound": "Nenhum canal encontrado", + "@noChannelsFound": { + "description": "Message when no channels match the search" + }, + "newMessage": "Nova mensagem", + "@newMessage": { + "description": "Notification title for new message" + }, + "channel": "Canal", + "@channel": { + "description": "Channel label in notifications" + }, + "samplePoliceLead": "Chefe da polícia", + "@samplePoliceLead": { + "description": "Sample team member name" + }, + "sampleDroneOperator": "Operador de drone", + "@sampleDroneOperator": { + "description": "Sample team member name" + }, + "sampleFirefighterAlpha": "Bombeiro", + "@sampleFirefighterAlpha": { + "description": "Sample team member name" + }, + "sampleMedicCharlie": "Paramédico", + "@sampleMedicCharlie": { + "description": "Sample team member name" + }, + "sampleCommandDelta": "Comando", + "@sampleCommandDelta": { + "description": "Sample team member name" + }, + "sampleFireEngine": "Caminhão de bombeiros", + "@sampleFireEngine": { + "description": "Sample team member name" + }, + "sampleAirSupport": "Apoio aéreo", + "@sampleAirSupport": { + "description": "Sample team member name" + }, + "sampleBaseCoordinator": "Coordenador da base", + "@sampleBaseCoordinator": { + "description": "Sample team member name" + }, + "channelEmergency": "Emergência", + "@channelEmergency": { + "description": "Emergency channel name" + }, + "channelCoordination": "Coordenação", + "@channelCoordination": { + "description": "Coordination channel name" + }, + "channelUpdates": "Atualizações", + "@channelUpdates": { + "description": "Updates channel name" + }, + "sampleTeamMember": "Membro de equipe de exemplo", + "@sampleTeamMember": { + "description": "Sample sender name" + }, + "sampleScout": "Batedor de exemplo", + "@sampleScout": { + "description": "Sample sender name" + }, + "sampleBase": "Base de exemplo", + "@sampleBase": { + "description": "Sample sender name" + }, + "sampleSearcher": "Buscador de exemplo", + "@sampleSearcher": { + "description": "Sample sender name" + }, + "sampleObjectBackpack": " Mochila encontrada - cor azul", + "@sampleObjectBackpack": { + "description": "Sample object note" + }, + "sampleObjectVehicle": " Veículo abandonado - verificar proprietário", + "@sampleObjectVehicle": { + "description": "Sample object note" + }, + "sampleObjectCamping": " Equipamento de camping encontrado", + "@sampleObjectCamping": { + "description": "Sample object note" + }, + "sampleObjectTrailMarker": " Marco de trilha encontrado fora da rota", + "@sampleObjectTrailMarker": { + "description": "Sample object note" + }, + "sampleMsgAllTeamsCheckIn": "Todas as equipes, façam check-in", + "@sampleMsgAllTeamsCheckIn": { + "description": "Sample channel message" + }, + "sampleMsgWeatherUpdate": "Atualização do tempo: céu limpo, temp. 18°C", + "@sampleMsgWeatherUpdate": { + "description": "Sample channel message" + }, + "sampleMsgBaseCamp": "Base estabelecida na área de apoio", + "@sampleMsgBaseCamp": { + "description": "Sample channel message" + }, + "sampleMsgTeamAlpha": "Equipe deslocando-se para o setor 2", + "@sampleMsgTeamAlpha": { + "description": "Sample channel message" + }, + "sampleMsgRadioCheck": "Teste de rádio - todas as estações respondam", + "@sampleMsgRadioCheck": { + "description": "Sample channel message" + }, + "sampleMsgWaterSupply": "Abastecimento de água disponível no ponto de controle 3", + "@sampleMsgWaterSupply": { + "description": "Sample channel message" + }, + "sampleMsgTeamBravo": "Equipe informa: setor 1 livre", + "@sampleMsgTeamBravo": { + "description": "Sample channel message" + }, + "sampleMsgEtaRallyPoint": "ETA para o ponto de reunião: 15 minutos", + "@sampleMsgEtaRallyPoint": { + "description": "Sample channel message" + }, + "sampleMsgSupplyDrop": "Entrega de suprimentos confirmada para 14:00", + "@sampleMsgSupplyDrop": { + "description": "Sample channel message" + }, + "sampleMsgDroneSurvey": "Levantamento com drone concluído - nada encontrado", + "@sampleMsgDroneSurvey": { + "description": "Sample channel message" + }, + "sampleMsgTeamCharlie": "Equipe solicitando reforço", + "@sampleMsgTeamCharlie": { + "description": "Sample channel message" + }, + "sampleMsgRadioDiscipline": "Todas as unidades: mantenham disciplina de rádio", + "@sampleMsgRadioDiscipline": { + "description": "Sample channel message" + }, + "sampleMsgUrgentMedical": "URGENTE: assistência médica necessária no setor 4", + "@sampleMsgUrgentMedical": { + "description": "Sample emergency message" + }, + "sampleMsgAdultMale": " Homem adulto, consciente", + "@sampleMsgAdultMale": { + "description": "Sample emergency message note" + }, + "sampleMsgFireSpotted": "Incêndio avistado - coordenadas a seguir", + "@sampleMsgFireSpotted": { + "description": "Sample emergency message" + }, + "sampleMsgSpreadingRapidly": " Se espalhando rapidamente!", + "@sampleMsgSpreadingRapidly": { + "description": "Sample emergency message note" + }, + "sampleMsgPriorityHelicopter": "PRIORIDADE: necessidade de apoio de helicóptero", + "@sampleMsgPriorityHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgMedicalTeamEnRoute": "Equipe médica a caminho da sua localização", + "@sampleMsgMedicalTeamEnRoute": { + "description": "Sample emergency message" + }, + "sampleMsgEvacHelicopter": "ETA do helicóptero de evacuação: 10 minutos", + "@sampleMsgEvacHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyResolved": "Emergência resolvida - tudo limpo", + "@sampleMsgEmergencyResolved": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyStagingArea": " Área de apoio de emergência", + "@sampleMsgEmergencyStagingArea": { + "description": "Sample emergency message note" + }, + "sampleMsgEmergencyServices": "Serviços de emergência notificados e em deslocamento", + "@sampleMsgEmergencyServices": { + "description": "Sample emergency message" + }, + "sampleAlphaTeamLead": "Líder da equipe", + "@sampleAlphaTeamLead": { + "description": "Sample team name" + }, + "sampleBravoScout": "Batedor", + "@sampleBravoScout": { + "description": "Sample team name" + }, + "sampleCharlieMedic": "Paramédico", + "@sampleCharlieMedic": { + "description": "Sample team name" + }, + "sampleDeltaNavigator": "Navegador", + "@sampleDeltaNavigator": { + "description": "Sample team name" + }, + "sampleEchoSupport": "Apoio", + "@sampleEchoSupport": { + "description": "Sample team name" + }, + "sampleBaseCommand": "Comando da base", + "@sampleBaseCommand": { + "description": "Sample team name" + }, + "sampleFieldCoordinator": "Coordenador de campo", + "@sampleFieldCoordinator": { + "description": "Sample team name" + }, + "sampleMedicalTeam": "Equipe médica", + "@sampleMedicalTeam": { + "description": "Sample team name" + }, + "mapDrawing": "Desenho do mapa", + "@mapDrawing": { + "description": "Label for map drawing messages" + }, + "navigateToDrawing": "Navegar até o desenho", + "@navigateToDrawing": { + "description": "Option to navigate to drawing on map" + }, + "copyCoordinates": "Copiar coordenadas", + "@copyCoordinates": { + "description": "Option to copy coordinates to clipboard" + }, + "hideFromMap": "Ocultar do mapa", + "@hideFromMap": { + "description": "Option to hide drawing from map" + }, + "lineDrawing": "Desenho de linha", + "@lineDrawing": { + "description": "Label for line type drawings" + }, + "rectangleDrawing": "Desenho de retângulo", + "@rectangleDrawing": { + "description": "Label for rectangle type drawings" + }, + "manualCoordinates": "Coordenadas manuais", + "@manualCoordinates": { + "description": "Label for manual coordinate input toggle" + }, + "enterCoordinatesManually": "Inserir coordenadas manualmente", + "@enterCoordinatesManually": { + "description": "Description for manual coordinate input option" + }, + "latitudeLabel": "Latitude", + "@latitudeLabel": { + "description": "Label for latitude input field" + }, + "longitudeLabel": "Longitude", + "@longitudeLabel": { + "description": "Label for longitude input field" + }, + "exampleCoordinates": "Exemplo: 46.0569, 14.5058", + "@exampleCoordinates": { + "description": "Example coordinate format hint" + }, + "shareDrawing": "Compartilhar desenho", + "@shareDrawing": { + "description": "Title for share single drawing dialog" + }, + "shareWithAllNearbyDevices": "Compartilhar com todos os dispositivos próximos", + "@shareWithAllNearbyDevices": { + "description": "Subtitle for public channel sharing option" + }, + "shareToRoom": "Compartilhar na sala", + "@shareToRoom": { + "description": "Header for room sharing section" + }, + "sendToPersistentStorage": "Enviar para armazenamento persistente da sala", + "@sendToPersistentStorage": { + "description": "Subtitle for room sharing option" + }, + "deleteDrawingConfirm": "Tem certeza de que deseja excluir este desenho?", + "@deleteDrawingConfirm": { + "description": "Confirmation message for deleting a single drawing" + }, + "drawingDeleted": "Desenho excluído", + "@drawingDeleted": { + "description": "Success message after deleting a drawing" + }, + "yourDrawingsCount": "Seus desenhos ({count})", + "@yourDrawingsCount": { + "description": "Header showing count of user's drawings", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shared": "Compartilhado", + "@shared": { + "description": "Status label for shared drawings" + }, + "line": "Linha", + "@line": { + "description": "Line drawing type label" + }, + "rectangle": "Retângulo", + "@rectangle": { + "description": "Rectangle drawing type label" + }, + "updateAvailable": "Atualização disponível", + "@updateAvailable": { + "description": "Title for update dialog when new version is available" + }, + "currentVersion": "Atual", + "@currentVersion": { + "description": "Label for current app version" + }, + "latestVersion": "Mais recente", + "@latestVersion": { + "description": "Label for latest available app version" + }, + "downloadUpdate": "Baixar", + "@downloadUpdate": { + "description": "Button to download app update" + }, + "updateLater": "Mais tarde", + "@updateLater": { + "description": "Button to dismiss update dialog" + }, + "cadastralParcels": "Parcelas cadastrais", + "@cadastralParcels": { + "description": "Label for cadastral parcels WMS overlay layer" + }, + "forestRoads": "Estradas florestais", + "@forestRoads": { + "description": "Label for forest roads WMS overlay layer" + }, + "wmsOverlays": "Sobreposições WMS", + "@wmsOverlays": { + "description": "Section header for WMS overlay layers in layer selector" + }, + "hikingTrails": "Trilhas de caminhada", + "@hikingTrails": { + "description": "Label for hiking/mountain trails WMS overlay layer" + }, + "mainRoads": "Estradas principais", + "@mainRoads": { + "description": "Label for main roads WMS overlay layer" + }, + "houseNumbers": "Números de porta", + "@houseNumbers": { + "description": "Label for house numbers WMS overlay layer" + }, + "fireHazardZones": "Zonas de risco de incêndio", + "@fireHazardZones": { + "description": "Label for fire hazard risk zones WMS overlay layer" + }, + "historicalFires": "Incêndios históricos", + "@historicalFires": { + "description": "Label for historical forest fires WMS overlay layer" + }, + "firebreaks": "Aceiros", + "@firebreaks": { + "description": "Label for firebreaks WMS overlay layer" + }, + "krasFireZones": "Zonas de incêndio do Karst", + "@krasFireZones": { + "description": "Label for Kras fire zones WMS overlay layer" + }, + "placeNames": "Topônimos", + "@placeNames": { + "description": "Label for geographic place names WMS overlay layer" + }, + "municipalityBorders": "Limites municipais", + "@municipalityBorders": { + "description": "Label for municipality borders WMS overlay layer" + }, + "topographicMap": "Mapa topográfico 1:25000", + "@topographicMap": { + "description": "Label for DTK25 topographic base map layer" + }, + "recentMessages": "Mensagens recentes", + "@recentMessages": { + "description": "Header for recent messages overlay on map in fullscreen mode" + }, + "addChannel": "Adicionar canal", + "@addChannel": { + "description": "Button to add a new channel" + }, + "channelName": "Nome do canal", + "@channelName": { + "description": "Label for channel name field" + }, + "channelNameHint": "ex.: Equipe de Resgate Alfa", + "@channelNameHint": { + "description": "Hint for channel name field" + }, + "channelSecret": "Segredo do canal", + "@channelSecret": { + "description": "Label for channel secret field" + }, + "channelSecretHint": "Senha compartilhada para este canal", + "@channelSecretHint": { + "description": "Hint for channel secret field" + }, + "channelSecretHelp": "Este segredo deve ser compartilhado com todos os membros da equipe que precisam de acesso a este canal", + "@channelSecretHelp": { + "description": "Help text explaining channel secret" + }, + "channelTypesInfo": "Canais hash (#team): o segredo é gerado automaticamente a partir do nome. Mesmo nome = mesmo canal em todos os dispositivos.\n\nCanais privados: use um segredo explícito. Somente quem tiver o segredo poderá entrar.", + "@channelTypesInfo": { + "description": "Information banner explaining hash and private channel types" + }, + "hashChannelInfo": "Canal hash: o segredo será gerado automaticamente a partir do nome do canal. Qualquer pessoa usando o mesmo nome entrará no mesmo canal.", + "@hashChannelInfo": { + "description": "Help text for hash channels (# prefix)" + }, + "channelNameRequired": "O nome do canal é obrigatório", + "@channelNameRequired": { + "description": "Validation error for empty channel name" + }, + "channelNameTooLong": "O nome do canal deve ter no máximo 31 caracteres", + "@channelNameTooLong": { + "description": "Validation error for channel name too long" + }, + "channelSecretRequired": "O segredo do canal é obrigatório", + "@channelSecretRequired": { + "description": "Validation error for empty channel secret" + }, + "channelSecretTooLong": "O segredo do canal deve ter no máximo 32 caracteres", + "@channelSecretTooLong": { + "description": "Validation error for channel secret too long" + }, + "invalidAsciiCharacters": "Somente caracteres ASCII são permitidos", + "@invalidAsciiCharacters": { + "description": "Validation error for non-ASCII characters" + }, + "channelCreatedSuccessfully": "Canal criado com sucesso", + "@channelCreatedSuccessfully": { + "description": "Success message after creating channel" + }, + "channelCreationFailed": "Falha ao criar canal: {error}", + "@channelCreationFailed": { + "description": "Error message when channel creation fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deleteChannel": "Excluir canal", + "@deleteChannel": { + "description": "Delete channel button/menu item" + }, + "deleteChannelConfirmation": "Tem certeza de que deseja excluir o canal \"{channelName}\"? Esta ação não pode ser desfeita.", + "@deleteChannelConfirmation": { + "description": "Confirmation dialog when deleting a channel", + "placeholders": { + "channelName": { + "type": "String" + } + } + }, + "channelDeletedSuccessfully": "Canal excluído com sucesso", + "@channelDeletedSuccessfully": { + "description": "Success message after deleting channel" + }, + "channelDeletionFailed": "Falha ao excluir canal: {error}", + "@channelDeletionFailed": { + "description": "Error message when channel deletion fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "createChannel": "Criar canal", + "@createChannel": { + "description": "Button text for creating a channel" + }, + "wizardBack": "Voltar", + "@wizardBack": { + "description": "Wizard back button text" + }, + "wizardSkip": "Pular", + "@wizardSkip": { + "description": "Wizard skip button text" + }, + "wizardNext": "Próximo", + "@wizardNext": { + "description": "Wizard next button text" + }, + "wizardGetStarted": "Começar", + "@wizardGetStarted": { + "description": "Wizard final button text to complete onboarding" + }, + "wizardWelcomeTitle": "Bem-vindo ao MeshCore SAR", + "@wizardWelcomeTitle": { + "description": "Welcome wizard first page title" + }, + "viewWelcomeTutorial": "Ver tutorial de boas-vindas", + "@viewWelcomeTutorial": { + "description": "Settings option to re-show welcome wizard" + }, + "allTeamContacts": "Todos os contatos da equipe", + "@allTeamContacts": { + "description": "Destination option to send SAR marker to all team contacts" + }, + "directMessagesInfo": "Mensagens diretas com ACKs. Enviadas para {count} membros da equipe.", + "@directMessagesInfo": { + "description": "Information about sending to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "sarMarkerSentToContacts": "Marcador SAR enviado para {count} contatos", + "@sarMarkerSentToContacts": { + "description": "Success message after sending SAR marker to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noContactsAvailable": "Nenhum contato da equipe disponível", + "@noContactsAvailable": { + "description": "Message when there are no chat contacts to send to" + }, + "reply": "Responder", + "@reply": {}, + "technicalDetails": "Detalhes técnicos", + "@technicalDetails": {}, + "messageTechnicalDetails": "Detalhes técnicos da mensagem", + "@messageTechnicalDetails": {}, + "linkQuality": "Qualidade do link", + "@linkQuality": {}, + "delivery": "Entrega", + "@delivery": {}, + "status": "Status", + "@status": {}, + "expectedAckTag": "Tag ACK esperada", + "@expectedAckTag": {}, + "roundTrip": "Ida e volta", + "@roundTrip": {}, + "retryAttempt": "Tentativa de repetição", + "@retryAttempt": {}, + "floodFallback": "Fallback de flooding", + "@floodFallback": {}, + "identity": "Identidade", + "@identity": {}, + "messageId": "ID da mensagem", + "@messageId": {}, + "sender": "Remetente", + "@sender": {}, + "senderKey": "Chave do remetente", + "@senderKey": {}, + "recipient": "Destinatário", + "@recipient": {}, + "recipientKey": "Chave do destinatário", + "@recipientKey": {}, + "voice": "Voz", + "@voice": {}, + "voiceId": "ID de voz", + "@voiceId": {}, + "envelope": "Envelope", + "@envelope": {}, + "sessionProgress": "Progresso da sessão", + "@sessionProgress": {}, + "complete": "Concluído", + "@complete": {}, + "rawDump": "Dump bruto", + "@rawDump": {}, + "cannotRetryMissingRecipient": "Não é possível repetir: faltam informações do destinatário", + "@cannotRetryMissingRecipient": {}, + "voiceUnavailable": "A voz não está disponível no momento", + "@voiceUnavailable": {}, + "requestingVoice": "Solicitando voz", + "@requestingVoice": {}, + "device": "dispositivo", + "change": "Alterar", + "wizardOverviewDescription": "Este aplicativo reúne mensagens MeshCore, atualizações SAR em campo, mapas e ferramentas do dispositivo em um só lugar.", + "wizardOverviewFeature1": "Envie mensagens diretas, publicações em salas e mensagens de canal pela guia principal Mensagens.", + "wizardOverviewFeature2": "Compartilhe marcadores SAR, desenhos de mapa, clipes de voz e imagens pela malha.", + "wizardOverviewFeature3": "Conecte-se via BLE ou TCP e depois gerencie o rádio complementar dentro do aplicativo.", + "wizardMessagingTitle": "Mensagens e relatórios de campo", + "wizardMessagingDescription": "Aqui, as mensagens são mais do que texto simples. O aplicativo já oferece suporte a várias cargas operacionais e fluxos de transferência.", + "wizardMessagingFeature1": "Envie mensagens diretas, publicações em salas e tráfego de canal a partir de um único compositor.", + "wizardMessagingFeature2": "Crie atualizações SAR e modelos SAR reutilizáveis para relatórios de campo comuns.", + "wizardMessagingFeature3": "Transfira sessões de voz e imagens com progresso e estimativas de tempo de transmissão na interface.", + "wizardConnectDeviceTitle": "Conectar dispositivo", + "wizardConnectDeviceDescription": "Conecte seu rádio MeshCore, escolha um nome e aplique uma predefinição de rádio antes de continuar.", + "wizardSetupBadge": "Configuração", + "wizardOverviewBadge": "Visão geral", + "wizardConnectedToDevice": "Conectado a {deviceName}", + "wizardNoDeviceConnected": "Nenhum dispositivo conectado ainda", + "wizardSkipForNow": "Ignorar por enquanto", + "wizardDeviceNameLabel": "Nome do dispositivo", + "wizardDeviceNameHelp": "Esse nome é anunciado para outros usuários do MeshCore.", + "wizardConfigRegionLabel": "Região de configuração", + "wizardConfigRegionHelp": "Usa a lista oficial completa de predefinições do MeshCore. O padrão é EU/UK (Narrow).", + "wizardPresetNote1": "Certifique-se de que a predefinição selecionada corresponda às regulamentações locais de rádio.", + "wizardPresetNote2": "A lista corresponde ao feed oficial de predefinições da ferramenta de configuração do MeshCore.", + "wizardPresetNote3": "EU/UK (Narrow) permanece selecionado por padrão durante a configuração inicial.", + "wizardSaving": "Salvando...", + "wizardSaveAndContinue": "Salvar e continuar", + "wizardEnterDeviceName": "Digite um nome para o dispositivo antes de continuar.", + "wizardDeviceSetupSaved": "{deviceName} salvo com {presetName}.", + "wizardNetworkTitle": "Contatos, salas e repetidores", + "wizardNetworkDescription": "A guia Contatos organiza a rede que você descobre e as rotas aprendidas ao longo do tempo.", + "wizardNetworkFeature1": "Revise membros da equipe, repetidores, salas, canais e anúncios pendentes em uma única lista.", + "wizardNetworkFeature2": "Use ping inteligente, login em salas, caminhos aprendidos e ferramentas de redefinição de rota quando a conectividade ficar confusa.", + "wizardNetworkFeature3": "Crie canais e gerencie destinos de rede sem sair do aplicativo.", + "wizardMapOpsTitle": "Mapa, trilhas e geometria compartilhada", + "wizardMapOpsDescription": "O mapa do aplicativo está ligado diretamente às mensagens, ao rastreamento e às sobreposições SAR, em vez de ser um visualizador separado.", + "wizardMapOpsFeature1": "Acompanhe sua própria posição, as localizações da equipe e as trilhas de movimento no mapa.", + "wizardMapOpsFeature2": "Abra desenhos de mensagens, visualize-os em linha e remova-os do mapa quando necessário.", + "wizardMapOpsFeature3": "Use vistas de mapa de repetidores e sobreposições compartilhadas para entender o alcance da rede em campo.", + "wizardToolsTitle": "Ferramentas além das mensagens", + "wizardToolsDescription": "Há mais aqui do que as quatro guias principais. O aplicativo também inclui configuração, diagnóstico e fluxos opcionais de sensores.", + "wizardToolsFeature1": "Abra a configuração do dispositivo para alterar ajustes de rádio, telemetria, potência TX e detalhes do equipamento complementar.", + "wizardToolsFeature2": "Ative a guia Sensores quando quiser painéis monitorados e ações de atualização rápida.", + "wizardToolsFeature3": "Use logs de pacotes, varredura de espectro e diagnóstico de desenvolvedor ao solucionar problemas da malha.", + "contactInSensors": "Nos Sensores", + "contactAddToSensors": "Adicionar aos Sensores", + "contactSetPath": "Definir rota", + "contactAddedToSensors": "{contactName} adicionado a Sensores", + "contactFailedToClearRoute": "Falha ao limpar a rota: {error}", + "contactRouteCleared": "Rota limpa", + "contactRouteSet": "Rota definida: {route}", + "contactFailedToSetRoute": "Falha ao definir a rota: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "Tempo limite ACK", + "opcode": "Opcode", + "payload": "Carga útil", + "hops": "Saltos", + "hashSize": "Tamanho do hash", + "pathBytes": "Bytes do caminho", + "selectedPath": "Caminho selecionado", + "estimatedTx": "Transmissão estimada", + "senderToReceipt": "Do remetente ao recibo", + "receivedCopies": "Cópias recebidas", + "retryCause": "Causa da nova tentativa", + "retryMode": "Modo de nova tentativa", + "retryResult": "Resultado da nova tentativa", + "lastRetry": "Última tentativa", + "rxPackets": "Pacotes RX", + "mesh": "Mesh", + "rate": "Taxa", + "window": "Janela", + "posttxDelay": "Atraso pós-tx", + "bandpass": "Passa-banda", + "bandpassFilterVoice": "Filtro passa-banda de voz", + "active": "Ativo", + "addContact": "Adicionar contacto", + "all": "Tudo", + "clearAllLabel": "Limpar tudo", + "clearFilters": "Limpar filtros", + "clearRoute": "Limpar rota", + "clearMessages": "Limpar mensagens", + "clearScale": "Limpar escala", + "clearDiscoveries": "Limpar descobertas", + "clearOnlineTraceDatabase": "Limpar base de dados de rastreio", + "clearAllChannels": "Limpar todos os canais", + "clearAllContacts": "Limpar todos os contactos", + "clearChannels": "Limpar canais", + "clearContacts": "Limpar contactos", + "clearPathOnMaxRetry": "Limpar caminho no máx. de tentativas", + "create": "Criar", + "custom": "Personalizado", + "defaultValue": "Padrão", + "duplicate": "Duplicar", + "editName": "Editar nome", + "open": "Abrir", + "paste": "Colar", + "preview": "Pré-visualização", + "remove": "Remover", + "rename": "Renomear", + "resolveAll": "Resolver tudo", + "send": "Enviar", + "sendAnyway": "Enviar mesmo assim", + "share": "Partilhar", + "trace": "Rastreio", + "discovery": "Descoberta", + "discoverRepeaters": "Descobrir repetidores", + "discoverSensors": "Descobrir sensores", + "repeaterDiscoverySent": "Descoberta de repetidores enviada", + "sensorDiscoverySent": "Descoberta de sensores enviada", + "clearedPendingDiscoveries": "Descobertas pendentes limpas.", + "autoDiscovery": "Descoberta automática", + "enableAutomaticAdding": "Ativar adição automática", + "autoaddRepeaters": "Adicionar repetidores automaticamente", + "autoaddRoomServers": "Adicionar servidores de sala automaticamente", + "autoaddSensors": "Adicionar sensores automaticamente", + "autoaddUsers": "Adicionar utilizadores automaticamente", + "overwriteOldestWhenFull": "Sobrescrever mais antigos quando cheio", + "storage": "Armazenamento", + "dangerZone": "Zona de perigo", + "profiles": "Perfis", + "favourites": "Favoritos", + "sensors": "Sensores", + "others": "Outros", + "gpsModule": "Módulo GPS", + "liveTraffic": "Tráfego ao vivo", + "repeatersMap": "Mapa de repetidores", + "blePacketLogs": "Registos de pacotes BLE", + "onlineTraceDatabase": "Base de dados de rastreio", + "routePathByteSize": "Tamanho do caminho em bytes", + "messageNotifications": "Notificações de mensagens", + "sarAlerts": "Alertas SAR", + "discoveryNotifications": "Notificações de descoberta", + "updateNotifications": "Notificações de atualização", + "muteWhileAppIsOpen": "Silenciar com a app aberta", + "disableContacts": "Desativar contactos", + "enableSensorsTab": "Ativar separador Sensores", + "enableProfiles": "Ativar perfis", + "nearestRepeaterFallback": "Repetidor mais próximo como reserva", + "deleteAllStoredMessageHistory": "Apagar todo o histórico de mensagens", + "messageFontSize": "Tamanho da fonte de mensagens", + "rotateMapWithHeading": "Rodar mapa com direção", + "showMapDebugInfo": "Mostrar info de depuração do mapa", + "openMapInFullscreen": "Abrir mapa em ecrã inteiro", + "showSarMarkersLabel": "Mostrar marcadores SAR", + "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", + "voiceLimiter": "Limitador de voz", + "micAutoGain": "Ganho automático do microfone", + "echoCancellation": "Cancelamento de eco", + "noiseSuppression": "Supressão de ruído", + "trimSilenceInVoiceMessages": "Cortar silêncio em mensagens de voz", + "compressor": "Compressor", + "limiter": "Limitador", + "autoGain": "Ganho automático", + "echoCancel": "Eco", + "noiseSuppress": "Ruído", + "silenceTrim": "Silêncio", + "maxImageSize": "Tamanho máximo de imagem", + "imageCompression": "Compressão de imagem", + "grayscale": "Escala de cinzentos", + "ultraMode": "Modo ultra", + "fastPrivateGpsUpdates": "Atualizações GPS privadas rápidas", + "movementThreshold": "Limiar de movimento", + "fastGpsMovementThreshold": "Limiar de movimento GPS rápido", + "fastGpsActiveuseInterval": "Intervalo de uso ativo GPS rápido", + "activeuseUpdateInterval": "Intervalo de atualização de uso ativo", + "repeatNearbyTraffic": "Repetir tráfego próximo", + "relayThroughRepeatersAcrossTheMesh": "Retransmitir através de repetidores na rede", + "nearbyOnlyWithoutRepeaterFlooding": "Apenas próximos, sem flood de repetidores", + "multihop": "Multi-salto", + "createProfile": "Criar perfil", + "renameProfile": "Renomear perfil", + "newProfile": "Novo perfil", + "manageProfiles": "Gerir perfis", + "enableProfilesToStartManagingThem": "Ative os perfis para começar a geri-los.", + "openMessage": "Abrir mensagem", + "jumpToTheRelatedSarMessage": "Ir para a mensagem SAR relacionada", + "removeSarMarker": "Remover marcador SAR", + "pleaseSelectADestinationToSendSarMarker": "Selecione um destino para enviar o marcador SAR", + "sarMarkerBroadcastToPublicChannel": "Marcador SAR transmitido no canal público", + "sarMarkerSentToRoom": "Marcador SAR enviado para a sala", + "loadFromGallery": "Carregar da galeria", + "replaceImage": "Substituir imagem", + "selectFromGallery": "Selecionar da galeria", + "team": "Equipa", + "found": "Encontrado", + "staging": "Área de reunião", + "object": "Objeto", + "quiet": "Silencioso", + "busy": "Ocupado", + "searchMessages": "Pesquisar mensagens", + "sendImageFromGallery": "Enviar imagem da galeria", + "takePhoto": "Tirar foto", + "allMessages": "Todas as mensagens", + "sendToPublicChannel": "Enviar para o canal público?", + "selectMarkerTypeAndDestination": "Selecione tipo de marcador e destino", + "noDestinationsAvailableLabel": "Nenhum destino disponível", + "image": "Imagem", + "format": "Formato", + "dimensions": "Dimensões", + "segments": "Segmentos", + "transfers": "Transferências", + "downloadedBy": "Transferido por", + "saveDiscoverySettings": "Guardar definições de descoberta", + "savePublicInfo": "Guardar informação pública", + "saveRadioSettings": "Guardar definições de rádio", + "savePath": "Guardar caminho", + "wipeDeviceData": "Apagar dados do dispositivo", + "wipeDevice": "Apagar dispositivo", + "destructiveDeviceActions": "Ações destrutivas do dispositivo.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Escolha uma predefinição ou ajuste as definições de rádio.", + "used": "Usado", + "total": "Total", + "renameValue": "Renomear valor", + "customizeFields": "Personalizar campos", + "livePreview": "Pré-visualização ao vivo", + "refreshSchedule": "Agenda de atualização", + "noResponse": "Sem resposta", + "refreshing": "A atualizar", + "unavailable": "Indisponível", + "pickARelayOrNodeToWatchInSensors": "Escolha um relay ou nó para observar.", + "publicKeyLabel": "Chave pública", + "alreadyInContacts": "Já nos contactos", + "connectToADeviceBeforeAddingContacts": "Conecte-se a um dispositivo antes de adicionar contactos", + "fromContacts": "Dos contactos", + "onlineOnly": "Apenas online", + "inBoth": "Em ambos", + "source": "Fonte", + "allMessagesCleared": "Todas as mensagens limpas", + "onlineTraceDatabaseCleared": "Base de dados de rastreio limpa", + "packetLogsCleared": "Registos de pacotes limpos", + "hexDataCopiedToClipboard": "Dados hex copiados para a área de transferência", + "developerModeEnabled": "Modo de programador ativado", + "developerModeDisabled": "Modo de programador desativado", + "clipboardIsEmpty": "A área de transferência está vazia", + "contactImported": "Contacto importado", + "contactLinkCopiedToClipboard": "Link do contacto copiado para a área de transferência", + "failedToExportContact": "Falha ao exportar contacto", + "noLogsToExport": "Sem registos para exportar", + "exportAsCsv": "Exportar como CSV", + "exportAsText": "Exportar como texto", + "receivedRfc3339": "Recebido (RFC3339)", + "buildTime": "Hora de compilação", + "downloadUrlNotAvailable": "URL de download não disponível", + "cannotOpenDownloadUrl": "Não é possível abrir o URL de download", + "updateCheckIsOnlyAvailableOnAndroid": "A verificação de atualizações só está disponível no Android", + "youAreRunningTheLatestVersion": "Está a usar a versão mais recente", + "updateAvailableButDownloadUrlNotFound": "Atualização disponível mas URL de download não encontrado", + "startTictactoe": "Iniciar Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe indisponível", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: oponente desconhecido", + "tictactoeWaitingForStart": "Tic-Tac-Toe: a aguardar início", + "acceptsShareLinks": "Aceita links partilhados", + "supportsRawHex": "Suporta hex bruto", + "clipboardfriendly": "Compatível com a área de transferência", + "captured": "Capturado", + "size": "Tamanho", + "noCustomChannelsToClear": "Sem canais personalizados para limpar.", + "noDeviceContactsToClear": "Sem contactos do dispositivo para limpar.", + "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", + "simpleMode": "Modo simples", + "simpleModeDescription": "Apenas chat e mapa com contatos favoritos", + "simpleModeRequiresLockedDestination": "Ative primeiro \"Bloquear destino\" com um canal para usar o modo simples", + "noFavouriteContacts": "Nenhum contato favorito", + "selectDestinationFirst": "Selecione primeiro um canal, contato ou sala", + "recipientNoLongerAvailable": "O destinatário não está mais disponível", + "failedToSendMessage": "Falha ao enviar: {error}", + "tictactoeDirectOnly": "O jogo da velha funciona apenas em mensagens diretas. Escolha primeiro um contato.", + "deviceKeyUnavailable": "A chave do dispositivo não está disponível", + "imageCompressionFailed": "Falha na compressão da imagem", + "imageFragmentationFailed": "Falha na fragmentação da imagem", + "failedToAnnounceImage": "Falha ao anunciar a imagem", + "noRecipientSelected": "Nenhum destinatário selecionado", + "imageSendFailed": "Falha no envio da imagem", + "microphonePermissionRequiredForVoice": "É necessária permissão de microfone para mensagens de voz", + "recordingDiscarded": "Gravação descartada", + "publicChannelMediaWarning": "Você está prestes a enviar {mediaType} para o canal público. Isso não é recomendado, pois todos na rede mesh podem receber. Escolha um canal privado ou com tag, a menos que seja isso que você quer.", + "selectPrivateChannelToShareLocation": "Selecione um canal privado para compartilhar sua localização.", + "stopSharingMyLocation": "Parar de compartilhar minha localização", + "shareMyLocation": "Compartilhar minha localização", + "stopRecording": "Parar gravação", + "recordVoice": "Gravar mensagem de voz", + "moreActions": "Mais ações", + "noMessagesInFilter": "Nenhuma mensagem neste filtro", + "noMatchesInFilter": "Nenhum resultado neste filtro", + "syncFailed": "Falha na sincronização: {error}", + "invalidLink": "Link inválido", + "cannotOpenLink": "Não é possível abrir o link", + "failedToOpenLink": "Falha ao abrir o link", + "failedToResendMessage": "Falha ao reenviar a mensagem", + "retryFailed": "Falha na nova tentativa: {error}", + "deleteDrawingAndMessage": "Excluir desenho e mensagem", + "lastEchoRelay": "Último eco: repetidor", + "lastEchoPath": "Último eco: caminho", + "lastEchoBytesReport": "Último eco: relatório de bytes", + "noGpsCoordinatesAvailable": "Nenhuma coordenada GPS disponível", + "notASarMarker": "Não é um marcador SAR", + "failedToSaveTemplate": "Falha ao salvar o modelo: {error}", + "drawingNotFound": "Desenho não encontrado", + "unknownDrawingType": "Tipo de desenho desconhecido", + "customMapMarker": "Marcador de mapa personalizado", + "customMapPointLabel": "Ponto: {point}", + "mapIdLabel": "ID do mapa: {id}", + "cannotFetchVoice": "Não é possível obter a voz", + "senderContactUnknown": "O contato do remetente é desconhecido. Sincronize os contatos primeiro.", + "senderRouteUnknown": "A rota do remetente é desconhecida. Sincronize os contatos/o caminho primeiro.", + "messageTooFar": "A mensagem está longe demais ({hops} saltos, máx. {maxHops}).", + "senderRouteNoPathResponse": "A rota do remetente não respondeu à verificação de caminho. Sincronize os contatos/o caminho e tente novamente.", + "senderRouteNoRawResponse": "A rota do remetente não respondeu no caminho de transporte raw.", + "voiceFetchOverHops": "Obter a voz por {hops} saltos pode demorar um pouco.", + "voiceReceiveCanceled": "Recebimento de voz cancelado", + "fetchingMissingVoiceFragments": "Obtendo fragmentos de voz ausentes", + "receivingVoice": "Recebendo voz", + "cannotFetchImage": "Não é possível obter a imagem", + "imageFetchOverHops": "Obter a imagem por {hops} saltos pode demorar um pouco.", + "imageAlreadyBeingReceived": "A imagem já está sendo recebida", + "loadImage": "Carregar imagem", + "imageFetchFailedToSendRequest": "Falha ao enviar a solicitação da imagem", + "imageUnavailable": "Imagem indisponível no momento", + "imageFetchTimedOut": "Tempo esgotado ao obter a imagem", + "imageReceiveCanceled": "Recebimento da imagem cancelado", + "closeImagePreview": "Fechar visualização da imagem", + "recordingReleaseToSend": "Gravando... solte para enviar", + "sendingVoice": "Enviando voz...", + "sendLongPressToRecordVoice": "Enviar (toque longo para gravar voz)", + "discardRecording": "Descartar gravação", + "hopCount": "{count, plural, =1{1 salto} other{{count} saltos}}", + "channelsSectionDescription": "Canais de difusão para o tráfego mesh próximo", + "roomsSectionDescription": "Espaços compartilhados para coordenação contínua da equipe", + "contactsSectionDescription": "Pessoas e dispositivos ao seu alcance direto", + "noRecentChatters": "Sem participantes recentes", + "public": "Público", + "failedToSendTicTacToeMove": "Falha ao enviar a jogada do jogo da velha", + "youWon": "Você venceu", + "opponentWon": "O adversário venceu", + "gameDraw": "Empate", + "yourTurn": "Sua vez", + "opponentTurn": "Vez do adversário", + "pleaseEnterValidCoordinates": "Insira coordenadas válidas", + "locationNotAvailable": "Localização não disponível", + "customCaveMapPoint": "Ponto de mapa de caverna personalizado", + "mapPoint": "Ponto do mapa", + "fetchingMissingImageFragments": "Obtendo fragmentos ausentes…", + "loadingImage": "Carregando…", + "receivingImage": "Recebendo…", + "tapToLoad": "Toque para carregar", + "connectDevice": "Conectar dispositivo", + "chooseTransportSubtitle": "Escolha o transporte Bluetooth, WiFi ou Serial", + "ble": "BLE", + "network": "Rede", + "serial": "Serial", + "scan": "Escanear", + "pressScanToSearchForDevices": "Pressione escanear para procurar dispositivos próximos", + "failedToConnectToDevice": "Falha ao conectar a {name}", + "unknownDevice": "Dispositivo desconhecido", + "showingCachedResultsTapRefresh": "Exibindo resultados em cache. Toque em atualizar para escanear novamente.", + "scanningLocalNetworkOnPort": "Escaneando a rede local em busca de dispositivos MeshCore WiFi na porta {port}", + "cancelScanAndAddServer": "Cancelar escaneamento e adicionar servidor", + "addServer": "Adicionar servidor", + "scanningProgressIps": "Escaneando... {scanned}/{total} IPs", + "cancelAndEnterManually": "Cancelar e inserir manualmente", + "noRecentOrDiscoveredServers": "Ainda não há servidores recentes ou descobertos", + "recentlyUsed": "Usados recentemente", + "connecting": "Conectando...", + "discoveredOnThisNetwork": "Descobertos nesta rede", + "serverNoLongerAvailable": "O servidor em {host}:{port} não está mais disponível. Escaneie novamente para encontrar servidores ativos.", + "failedToConnectToHost": "Falha ao conectar a {host}:{port}", + "enterValidIpAddress": "Insira um endereço IP válido", + "enterValidTcpPort": "Insira uma porta TCP válida", + "ipAddress": "Endereço IP", + "tcpPort": "Porta TCP", + "customServerPort": "Porta de servidor personalizada", + "chat": "Chat", + "restoringPreviousLink": "Restaurando a conexão anterior", + "noDeviceConnected": "Nenhum dispositivo conectado", + "sendAdvert": "Enviar meu contato", + "advertMode": "Enviar meu contato", + "advertModeSubtitle": "Escolha até onde suas informações de contato devem chegar.", + "floodAdvertSent": "Contato enviado por toda a rede mesh", + "directAdvertSent": "Contato enviado para os dispositivos próximos" +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 0000000..11c3b14 --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,1032 @@ +{ + "@@locale": "ru", + "appTitle": "MeshCore SAR", + "messages": "Сообщения", + "contacts": "Контакты", + "map": "Карта", + "settings": "Настройки", + "connect": "Подключить", + "disconnect": "Отключить", + "noDevicesFound": "Устройства не найдены", + "scanAgain": "Повторить поиск", + "deviceNotConnected": "Устройство не подключено", + "locationPermissionDenied": "Доступ к геолокации запрещён", + "locationPermissionPermanentlyDenied": "Доступ к геолокации запрещён навсегда. Включите его в настройках.", + "locationPermissionRequired": "Доступ к геолокации необходим для GPS-трекинга и координации команды. Вы можете включить его позже в настройках.", + "locationServicesDisabled": "Службы геолокации отключены. Пожалуйста, включите их в настройках.", + "failedToGetGpsLocation": "Не удалось получить GPS-координаты", + "failedToAdvertise": "Ошибка трансляции: {error}", + "cancelReconnection": "Отменить переподключение", + "general": "Основные", + "theme": "Тема", + "chooseTheme": "Выбрать тему", + "light": "Светлая", + "dark": "Тёмная", + "blueLightTheme": "Синяя светлая тема", + "blueDarkTheme": "Синяя тёмная тема", + "sarRed": "SAR Красная", + "alertEmergencyMode": "Режим тревоги / ЧС", + "sarGreen": "SAR Зелёная", + "safeAllClearMode": "Режим «Всё в порядке»", + "autoSystem": "Авто (Система)", + "followSystemTheme": "Следовать системной теме", + "showRxTxIndicators": "Показывать индикаторы RX/TX", + "disableMap": "Отключить карту", + "language": "Язык", + "chooseLanguage": "Выбрать язык", + "save": "Сохранить", + "cancel": "Отмена", + "close": "Закрыть", + "about": "О приложении", + "appVersion": "Версия приложения", + "aboutMeshCoreSar": "О MeshCore SAR", + "aboutDescription": "Приложение для поисково-спасательных операций, разработанное для аварийно-спасательных служб. Возможности:\n\n• BLE mesh-сеть для связи между устройствами\n• Офлайн-карты с несколькими вариантами слоёв\n• Отслеживание членов команды в реальном времени\n• Тактические маркеры SAR (найденный человек, пожар, место сбора)\n• Управление контактами и обмен сообщениями\n• GPS-трекинг с показанием курса компаса\n• Кэширование тайлов карт для работы офлайн", + "technologiesUsed": "Используемые технологии:", + "technologiesList": "• Flutter для кросс-платформенной разработки\n• BLE (Bluetooth Low Energy) для mesh-сети\n• OpenStreetMap для карт\n• Provider для управления состоянием\n• SharedPreferences для локального хранилища", + "moreInfo": "Подробнее", + "packageName": "Имя пакета", + "sampleData": "Тестовые данные", + "sampleDataDescription": "Загрузить или очистить тестовые контакты, сообщения каналов и маркеры SAR", + "loadSampleData": "Загрузить тестовые данные", + "clearAllData": "Очистить все данные", + "clearAllDataConfirmTitle": "Очистить все данные", + "clearAllDataConfirmMessage": "Это удалит все контакты и маркеры SAR. Вы уверены?", + "clear": "Очистить", + "loadedSampleData": "Загружено: {teamCount} членов команды, {channelCount} каналов, {sarCount} маркеров SAR, {messageCount} сообщений", + "failedToLoadSampleData": "Не удалось загрузить тестовые данные: {error}", + "allDataCleared": "Все данные очищены", + "failedToStartBackgroundTracking": "Не удалось запустить фоновое отслеживание. Проверьте разрешения и BLE-соединение.", + "locationBroadcast": "Трансляция геолокации: {latitude}, {longitude}", + "defaultPinInfo": "PIN-код по умолчанию для устройств без экрана — 123456. Проблемы с сопряжением? Удалите устройство из Bluetooth в системных настройках.", + "noMessagesYet": "Сообщений пока нет", + "pullDownToSync": "Потяните вниз для синхронизации сообщений", + "deleteContact": "Удалить контакт", + "delete": "Удалить", + "viewOnMap": "Показать на карте", + "refresh": "Обновить", + "resetPath": "Сбросить маршрут (перепроложить)", + "copiedToClipboard": "{label} скопировано в буфер обмена", + "pleaseEnterPassword": "Пожалуйста, введите пароль", + "failedToSyncContacts": "Не удалось синхронизировать контакты: {error}", + "loggedInSuccessfully": "Вход выполнен! Ожидание сообщений комнаты...", + "loginFailed": "Ошибка входа — неверный пароль", + "loggingIn": "Вход в {roomName}...", + "failedToSendLogin": "Не удалось отправить данные входа: {error}", + "lowLocationAccuracy": "Низкая точность геолокации", + "continue_": "Продолжить", + "sendSarMarker": "Отправить маркер SAR", + "deleteDrawing": "Удалить рисунок", + "drawingTools": "Инструменты рисования", + "drawLine": "Нарисовать линию", + "drawLineDesc": "Нарисуйте произвольную линию на карте", + "drawRectangle": "Нарисовать прямоугольник", + "drawRectangleDesc": "Нарисуйте прямоугольную область на карте", + "measureDistance": "Измерить расстояние", + "measureDistanceDesc": "Долгое нажатие на две точки для измерения", + "clearMeasurement": "Сбросить измерение", + "distanceLabel": "Расстояние: {distance}", + "longPressForSecondPoint": "Долгое нажатие для второй точки", + "longPressToStartMeasurement": "Долгое нажатие для установки первой точки", + "longPressToStartNewMeasurement": "Долгое нажатие для начала нового измерения", + "shareDrawings": "Поделиться рисунками", + "clearAllDrawings": "Удалить все рисунки", + "completeLine": "Завершить линию", + "broadcastDrawingsToTeam": "Транслировать {count} рисун{plural} команде", + "removeAllDrawings": "Удалить все {count} рисун{plural}", + "deleteAllDrawingsConfirm": "Удалить все {count} рисун{plural} с карты?", + "drawing": "Рисунок", + "shareDrawingsCount": "Поделиться {count} рисун{plural}", + "showReceivedDrawings": "Показать полученные рисунки", + "showingAllDrawings": "Показаны все рисунки", + "showingOnlyYourDrawings": "Показаны только ваши рисунки", + "showSarMarkers": "Показать маркеры SAR", + "showingSarMarkers": "Маркеры SAR отображаются", + "hidingSarMarkers": "Маркеры SAR скрыты", + "clearAll": "Очистить всё", + "publicChannel": "Публичный канал", + "broadcastToAll": "Трансляция всем ближайшим узлам (временно)", + "storedPermanently": "Сохранено постоянно в комнате", + "notConnectedToDevice": "Устройство не подключено", + "typeYourMessage": "Введите сообщение...", + "quickLocationMarker": "Быстрый маркер местоположения", + "markerType": "Тип маркера", + "sendTo": "Отправить в", + "noDestinationsAvailable": "Нет доступных получателей.", + "selectDestination": "Выберите получателя...", + "ephemeralBroadcastInfo": "Временно: передаётся по эфиру. Не сохраняется — узлы должны быть онлайн.", + "persistentRoomInfo": "Постоянно: хранится неизменно в комнате. Синхронизируется автоматически и доступно офлайн.", + "location": "Местоположение", + "fromMap": "С карты", + "gettingLocation": "Получение местоположения...", + "locationError": "Ошибка геолокации", + "retry": "Повторить", + "refreshLocation": "Обновить местоположение", + "accuracyMeters": "Точность: ±{accuracy}м", + "notesOptional": "Заметки (необязательно)", + "addAdditionalInformation": "Добавьте дополнительную информацию...", + "lowAccuracyWarning": "Точность геолокации: ±{accuracy}м. Этого может быть недостаточно для операций SAR.\n\nПродолжить всё равно?", + "loginToRoom": "Войти в комнату", + "enterPasswordInfo": "Введите пароль для доступа к этой комнате. Пароль будет сохранён для дальнейшего использования.", + "password": "Пароль", + "enterRoomPassword": "Введите пароль комнаты", + "loggingInDots": "Вход...", + "login": "Войти", + "failedToAddRoom": "Не удалось добавить комнату на устройство: {error}\n\nВозможно, комната ещё не объявила себя.\nПопробуйте подождать, пока комната не выйдет на связь.", + "direct": "Напрямую", + "flood": "Широковещательно", + "loggedIn": "Вход выполнен", + "noGpsData": "Нет данных GPS", + "distance": "Расстояние", + "directPingTimeout": "Таймаут прямого пинга — повтор {name} широковещательно...", + "pingFailed": "Пинг не удался: {name} — ответ не получен", + "deleteContactConfirmation": "Вы уверены, что хотите удалить \"{name}\"?\n\nЭто удалит контакт как из приложения, так и с сопряжённого радиоустройства.", + "failedToRemoveContact": "Не удалось удалить контакт: {error}", + "type": "Тип", + "publicKey": "Публичный ключ", + "lastSeen": "Последнее появление", + "yes": "Да", + "no": "Нет", + "permissions": "Разрешения", + "telemetry": "Телеметрия", + "voltage": "Напряжение", + "battery": "Батарея", + "temperature": "Температура", + "humidity": "Влажность", + "pressure": "Давление", + "gpsTelemetry": "GPS (телеметрия)", + "reLoginToRoom": "Войти в комнату повторно", + "heading": "Курс", + "elevation": "Высота", + "accuracy": "Точность", + "bearing": "Азимут", + "direction": "Направление", + "filterMarkers": "Фильтр маркеров", + "filterMarkersTooltip": "Фильтровать маркеры", + "contactsFilter": "Контакты", + "repeatersFilter": "Ретрансляторы", + "sarMarkers": "Маркеры SAR", + "foundPerson": "Найденный человек", + "fire": "Пожар", + "stagingArea": "Место сбора", + "showAll": "Показать все", + "locationUnavailable": "Местоположение недоступно", + "ahead": "впереди", + "degreesRight": "{degrees}° вправо", + "degreesLeft": "{degrees}° влево", + "latLonFormat": "Ш: {latitude} Д: {longitude}", + "noContactsYet": "Контактов пока нет", + "connectToDeviceToLoadContacts": "Подключите устройство для загрузки контактов", + "teamMembers": "Члены команды", + "repeaters": "Ретрансляторы", + "rooms": "Комнаты", + "channels": "Каналы", + "selectMapLayer": "Выбрать слой карты", + "openStreetMap": "OpenStreetMap", + "openTopoMap": "OpenTopoMap", + "esriSatellite": "ESRI Спутник", + "googleHybrid": "Google Гибрид", + "googleRoadmap": "Google Дороги", + "googleTerrain": "Google Рельеф", + "dragToPosition": "Перетащите для позиционирования", + "createSarMarker": "Создать маркер SAR", + "compass": "Компас", + "navigationAndContacts": "Навигация и контакты", + "sarAlert": "ТРЕВОГА SAR", + "textCopiedToClipboard": "Текст скопирован в буфер обмена", + "cannotReplySenderMissing": "Не удаётся ответить: нет информации об отправителе", + "cannotReplyContactNotFound": "Не удаётся ответить: контакт не найден", + "copyText": "Копировать текст", + "saveAsTemplate": "Сохранить как шаблон", + "templateSaved": "Шаблон успешно сохранён", + "templateAlreadyExists": "Шаблон с таким эмодзи уже существует", + "deleteMessage": "Удалить сообщение", + "deleteMessageConfirmation": "Вы уверены, что хотите удалить это сообщение?", + "shareLocation": "Поделиться местоположением", + "shareLocationText": "{markerInfo}\n\nКоординаты: {lat}, {lon}\n\nGoogle Maps: {url}", + "sarLocationShare": "Местоположение SAR", + "justNow": "Только что", + "minutesAgo": "{minutes}м назад", + "hoursAgo": "{hours}ч назад", + "daysAgo": "{days}д назад", + "secondsAgo": "{seconds}с назад", + "sending": "Отправка...", + "sent": "Отправлено", + "delivered": "Доставлено", + "deliveredWithTime": "Доставлено ({time}мс)", + "failed": "Ошибка", + "broadcast": "Трансляция", + "deliveredToContacts": "Доставлено {delivered}/{total} контактам", + "allDelivered": "Все доставлено", + "recipientDetails": "Детали получателей", + "pending": "Ожидание", + "sarMarkerFoundPerson": "Найденный человек", + "sarMarkerFire": "Место пожара", + "sarMarkerStagingArea": "Место сбора", + "sarMarkerObject": "Найденный объект", + "from": "От", + "coordinates": "Координаты", + "tapToViewOnMap": "Нажмите, чтобы открыть на карте", + "radioSettings": "Настройки радио", + "frequencyMHz": "Частота (МГц)", + "bandwidth": "Полоса пропускания", + "spreadingFactor": "Коэффициент расширения", + "codingRate": "Скорость кодирования", + "txPowerDbm": "Мощность TX (дБм)", + "maxPowerDbm": "Макс: {power} дБм", + "you": "Вы", + "exportFailed": "Ошибка экспорта: {error}", + "importFailed": "Ошибка импорта: {error}", + "unknown": "Неизвестно", + "onlineLayers": "Онлайн-слои", + "locationTrail": "Трек местоположения", + "showTrailOnMap": "Показать трек на карте", + "trailVisible": "Трек отображается на карте", + "trailHiddenRecording": "Трек скрыт (запись продолжается)", + "duration": "Продолжительность", + "points": "Точки", + "clearTrail": "Очистить трек", + "clearTrailQuestion": "Очистить трек?", + "clearTrailConfirmation": "Вы уверены, что хотите очистить текущий трек? Это действие нельзя отменить.", + "noTrailRecorded": "Трек ещё не записан", + "startTrackingToRecord": "Запустите отслеживание геолокации для записи трека", + "trailControls": "Управление треком", + "model": "Модель", + "version": "Версия", + "maxContacts": "Макс. контактов", + "maxChannels": "Макс. каналов", + "publicInfo": "Публичная информация", + "useCurrentLocation": "Использовать текущее местоположение", + "noneUnknown": "Нет/Неизвестно", + "chatNode": "Узел чата", + "repeater": "Ретранслятор", + "roomChannel": "Комната/Канал", + "typeNumber": "Тип {number}", + "failedToSave": "Не удалось сохранить: {error}", + "failedToGetLocation": "Не удалось получить местоположение: {error}", + "sarTemplates": "Шаблоны SAR", + "addTemplate": "Добавить шаблон", + "editTemplate": "Изменить шаблон", + "deleteTemplate": "Удалить шаблон", + "templateName": "Название шаблона", + "templateNameHint": "например, Найденный человек", + "templateEmoji": "Эмодзи", + "emojiRequired": "Требуется эмодзи", + "nameRequired": "Требуется название", + "templateDescription": "Описание (необязательно)", + "templateDescriptionHint": "Добавьте дополнительный контекст...", + "templateColor": "Цвет", + "previewFormat": "Предпросмотр (формат сообщения SAR)", + "importFromClipboard": "Импорт", + "exportToClipboard": "Экспорт", + "deleteTemplateConfirmation": "Удалить шаблон '{name}'?", + "templateAdded": "Шаблон добавлен", + "templateUpdated": "Шаблон обновлён", + "templateDeleted": "Шаблон удалён", + "templatesImported": "{count, plural, =0{Шаблоны не импортированы} =1{Импортирован 1 шаблон} other{Импортировано {count} шаблонов}}", + "templatesExported": "{count, plural, =1{1 шаблон экспортирован в буфер обмена} other{{count} шаблонов экспортировано в буфер обмена}}", + "resetToDefaults": "Сбросить до умолчаний", + "resetToDefaultsConfirmation": "Все пользовательские шаблоны будут удалены и восстановлены 4 шаблона по умолчанию. Продолжить?", + "reset": "Сбросить", + "resetComplete": "Шаблоны сброшены до умолчаний", + "noTemplates": "Шаблоны недоступны", + "tapAddToCreate": "Нажмите +, чтобы создать первый шаблон", + "ok": "ОК", + "locationPermission": "Разрешение геолокации", + "checking": "Проверка...", + "locationPermissionGrantedAlways": "Разрешено (всегда)", + "locationPermissionGrantedWhileInUse": "Разрешено (при использовании)", + "locationPermissionDeniedTapToRequest": "Запрещено — нажмите для запроса", + "locationPermissionPermanentlyDeniedOpenSettings": "Запрещено навсегда — откройте настройки", + "locationPermissionDialogContent": "Доступ к геолокации запрещён навсегда. Включите его в настройках устройства для GPS-трекинга и обмена местоположением.", + "openSettings": "Открыть настройки", + "locationPermissionGranted": "Разрешение геолокации получено!", + "locationPermissionRequiredForGps": "Разрешение геолокации необходимо для GPS-трекинга и обмена местоположением.", + "locationPermissionAlreadyGranted": "Разрешение геолокации уже предоставлено.", + "sarNavyBlue": "SAR Тёмно-синяя", + "sarNavyBlueDescription": "Профессиональный / оперативный режим", + "selectRecipient": "Выбрать получателя", + "broadcastToAllNearby": "Трансляция всем ближайшим", + "searchRecipients": "Поиск получателей...", + "noContactsFound": "Контакты не найдены", + "noRoomsFound": "Комнаты не найдены", + "noRecipientsAvailable": "Нет доступных получателей", + "noChannelsFound": "Каналы не найдены", + "newMessage": "Новое сообщение", + "channel": "Канал", + "samplePoliceLead": "Руководитель группы полиции", + "sampleDroneOperator": "Оператор дрона", + "sampleFirefighterAlpha": "Пожарный", + "sampleMedicCharlie": "Медик", + "sampleCommandDelta": "Командование", + "sampleFireEngine": "Пожарная машина", + "sampleAirSupport": "Авиационная поддержка", + "sampleBaseCoordinator": "Базовый координатор", + "channelEmergency": "Аварийный", + "channelCoordination": "Координация", + "channelUpdates": "Обновления", + "sampleTeamMember": "Тестовый член команды", + "sampleScout": "Тестовый разведчик", + "sampleBase": "Тестовая база", + "sampleSearcher": "Тестовый поисковик", + "sampleObjectBackpack": " Найден рюкзак синего цвета", + "sampleObjectVehicle": " Брошенный автомобиль — установить владельца", + "sampleObjectCamping": " Обнаружено туристическое снаряжение", + "sampleObjectTrailMarker": " Указатель тропы найден вне маршрута", + "sampleMsgAllTeamsCheckIn": "Всем группам: сообщить о готовности", + "sampleMsgWeatherUpdate": "Погода: ясно, температура 18°C", + "sampleMsgBaseCamp": "Базовый лагерь развёрнут у места сбора", + "sampleMsgTeamAlpha": "Группа выдвигается в сектор 2", + "sampleMsgRadioCheck": "Проверка связи — всем станциям ответить", + "sampleMsgWaterSupply": "Вода доступна на контрольной точке 3", + "sampleMsgTeamBravo": "Группа докладывает: сектор 1 чист", + "sampleMsgEtaRallyPoint": "Прибытие на место сбора: 15 минут", + "sampleMsgSupplyDrop": "Сброс снаряжения подтверждён на 14:00", + "sampleMsgDroneSurvey": "Разведка дроном завершена — ничего не обнаружено", + "sampleMsgTeamCharlie": "Группа запрашивает подкрепление", + "sampleMsgRadioDiscipline": "Всем подразделениям: соблюдать радиодисциплину", + "sampleMsgUrgentMedical": "СРОЧНО: нужна медицинская помощь в секторе 4", + "sampleMsgAdultMale": " Взрослый мужчина, в сознании", + "sampleMsgFireSpotted": "Обнаружен пожар — координаты следуют", + "sampleMsgSpreadingRapidly": " Распространяется быстро!", + "sampleMsgPriorityHelicopter": "ПРИОРИТЕТ: нужна поддержка вертолёта", + "sampleMsgMedicalTeamEnRoute": "Медицинская группа движется к вашему местоположению", + "sampleMsgEvacHelicopter": "Вертолёт эвакуации: прибытие через 10 минут", + "sampleMsgEmergencyResolved": "Чрезвычайная ситуация ликвидирована — опасности нет", + "sampleMsgEmergencyStagingArea": " Аварийное место сбора", + "sampleMsgEmergencyServices": "Аварийные службы уведомлены и реагируют", + "sampleAlphaTeamLead": "Руководитель группы", + "sampleBravoScout": "Разведчик", + "sampleCharlieMedic": "Медик", + "sampleDeltaNavigator": "Навигатор", + "sampleEchoSupport": "Поддержка", + "sampleBaseCommand": "Базовое командование", + "sampleFieldCoordinator": "Полевой координатор", + "sampleMedicalTeam": "Медицинская группа", + "mapDrawing": "Рисунок на карте", + "navigateToDrawing": "Перейти к рисунку", + "copyCoordinates": "Копировать координаты", + "hideFromMap": "Скрыть с карты", + "lineDrawing": "Линия", + "rectangleDrawing": "Прямоугольник", + "manualCoordinates": "Ввод координат вручную", + "enterCoordinatesManually": "Ввести координаты вручную", + "latitudeLabel": "Широта", + "longitudeLabel": "Долгота", + "exampleCoordinates": "Пример: 55.7558, 37.6173", + "shareDrawing": "Поделиться рисунком", + "shareWithAllNearbyDevices": "Поделиться со всеми ближайшими устройствами", + "shareToRoom": "Отправить в комнату", + "sendToPersistentStorage": "Отправить в постоянное хранилище комнаты", + "deleteDrawingConfirm": "Вы уверены, что хотите удалить этот рисунок?", + "drawingDeleted": "Рисунок удалён", + "yourDrawingsCount": "Ваши рисунки ({count})", + "shared": "Передано", + "line": "Линия", + "rectangle": "Прямоугольник", + "updateAvailable": "Доступно обновление", + "currentVersion": "Текущая", + "latestVersion": "Последняя", + "downloadUpdate": "Скачать", + "updateLater": "Позже", + "cadastralParcels": "Кадастровые участки", + "forestRoads": "Лесные дороги", + "wmsOverlays": "WMS-наложения", + "hikingTrails": "Туристические маршруты", + "mainRoads": "Главные дороги", + "houseNumbers": "Номера домов", + "fireHazardZones": "Пожароопасные зоны", + "historicalFires": "Исторические пожары", + "firebreaks": "Противопожарные просеки", + "krasFireZones": "Пожарные зоны Краса", + "placeNames": "Названия мест", + "municipalityBorders": "Границы муниципалитетов", + "topographicMap": "Топографическая карта 1:25000", + "recentMessages": "Последние сообщения", + "addChannel": "Добавить канал", + "channelName": "Название канала", + "channelNameHint": "например, Группа спасения Альфа", + "channelSecret": "Секрет канала", + "channelSecretHint": "Общий пароль для этого канала", + "channelSecretHelp": "Этот секрет необходимо передать всем членам команды, которым нужен доступ к каналу", + "channelTypesInfo": "Hash-каналы (#команда): секрет генерируется из названия автоматически. Одно название = один канал на всех устройствах.\n\nЗакрытые каналы: используется явный секрет. Войти могут только те, у кого есть секрет.", + "hashChannelInfo": "Hash-канал: секрет будет автоматически создан из названия. Все, кто использует одно и то же имя, окажутся в одном канале.", + "channelNameRequired": "Требуется название канала", + "channelNameTooLong": "Название канала не должно превышать 31 символ", + "channelSecretRequired": "Требуется секрет канала", + "channelSecretTooLong": "Секрет канала не должен превышать 32 символа", + "invalidAsciiCharacters": "Разрешены только символы ASCII", + "channelCreatedSuccessfully": "Канал успешно создан", + "channelCreationFailed": "Не удалось создать канал: {error}", + "deleteChannel": "Удалить канал", + "deleteChannelConfirmation": "Вы уверены, что хотите удалить канал \"{channelName}\"? Это действие нельзя отменить.", + "channelDeletedSuccessfully": "Канал успешно удалён", + "channelDeletionFailed": "Не удалось удалить канал: {error}", + "createChannel": "Создать канал", + "wizardBack": "Назад", + "wizardSkip": "Пропустить", + "wizardNext": "Далее", + "wizardGetStarted": "Начать", + "wizardWelcomeTitle": "Добро пожаловать в MeshCore SAR", + "viewWelcomeTutorial": "Просмотреть обучение", + "allTeamContacts": "Все контакты команды", + "directMessagesInfo": "Личные сообщения с подтверждением. Отправлено {count} членам команды.", + "sarMarkerSentToContacts": "Маркер SAR отправлен {count} контактам", + "noContactsAvailable": "Нет доступных контактов команды", + "reply": "Ответить", + "technicalDetails": "Технические детали", + "messageTechnicalDetails": "Технические детали сообщения", + "linkQuality": "Качество связи", + "delivery": "Доставка", + "status": "Статус", + "expectedAckTag": "Ожидаемый тег ACK", + "roundTrip": "Время отклика", + "retryAttempt": "Попытка повтора", + "floodFallback": "Широковещательный резерв", + "identity": "Идентификатор", + "messageId": "ID сообщения", + "sender": "Отправитель", + "senderKey": "Ключ отправителя", + "recipient": "Получатель", + "recipientKey": "Ключ получателя", + "voice": "Голос", + "voiceId": "ID голоса", + "envelope": "Конверт", + "sessionProgress": "Прогресс сессии", + "complete": "Завершено", + "rawDump": "Необработанные данные", + "cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе", + "voiceUnavailable": "Голос сейчас недоступен", + "requestingVoice": "Запрос голоса", + "device": "устройство", + "change": "Изменить", + "wizardOverviewDescription": "Это приложение объединяет сообщения MeshCore, полевые обновления SAR, карты и инструменты устройства в одном месте.", + "wizardOverviewFeature1": "Отправляйте личные сообщения, сообщения комнат и сообщения каналов из основной вкладки «Сообщения».", + "wizardOverviewFeature2": "Делитесь SAR-маркерами, рисунками на карте, голосовыми клипами и изображениями через mesh-сеть.", + "wizardOverviewFeature3": "Подключайтесь по BLE или TCP, а затем управляйте сопутствующей радиостанцией прямо из приложения.", + "wizardMessagingTitle": "Сообщения и полевые отчёты", + "wizardMessagingDescription": "Здесь сообщения это больше, чем просто текст. Приложение уже поддерживает несколько видов операционных данных и сценариев передачи.", + "wizardMessagingFeature1": "Отправляйте личные сообщения, сообщения комнат и трафик каналов из одного редактора.", + "wizardMessagingFeature2": "Создавайте обновления SAR и повторно используемые шаблоны SAR для типовых полевых отчётов.", + "wizardMessagingFeature3": "Передавайте голосовые сессии и изображения с индикатором прогресса и оценкой эфирного времени в интерфейсе.", + "wizardConnectDeviceTitle": "Подключить устройство", + "wizardConnectDeviceDescription": "Подключите свою радиостанцию MeshCore, выберите имя и примените радиопрофиль перед продолжением.", + "wizardSetupBadge": "Настройка", + "wizardOverviewBadge": "Обзор", + "wizardConnectedToDevice": "Подключено к {deviceName}", + "wizardNoDeviceConnected": "Устройство ещё не подключено", + "wizardSkipForNow": "Пропустить пока", + "wizardDeviceNameLabel": "Имя устройства", + "wizardDeviceNameHelp": "Это имя транслируется другим пользователям MeshCore.", + "wizardConfigRegionLabel": "Регион конфигурации", + "wizardConfigRegionHelp": "Используется полный официальный список профилей MeshCore. По умолчанию выбран EU/UK (Narrow).", + "wizardPresetNote1": "Убедитесь, что выбранный профиль соответствует местным радиоправилам.", + "wizardPresetNote2": "Список соответствует официальному потоку профилей инструмента MeshCore config.", + "wizardPresetNote3": "Для онбординга по умолчанию остаётся выбран EU/UK (Narrow).", + "wizardSaving": "Сохранение...", + "wizardSaveAndContinue": "Сохранить и продолжить", + "wizardEnterDeviceName": "Введите имя устройства перед продолжением.", + "wizardDeviceSetupSaved": "Сохранено: {deviceName}, профиль {presetName}.", + "wizardNetworkTitle": "Контакты, комнаты и ретрансляторы", + "wizardNetworkDescription": "Вкладка «Контакты» организует сеть, которую вы обнаруживаете, и маршруты, которые вы узнаёте со временем.", + "wizardNetworkFeature1": "Просматривайте участников команды, ретрансляторы, комнаты, каналы и ожидающие объявления в одном списке.", + "wizardNetworkFeature2": "Используйте smart ping, вход в комнаты, изученные пути и инструменты сброса маршрутов, когда связь становится нестабильной.", + "wizardNetworkFeature3": "Создавайте каналы и управляйте сетевыми назначениями, не покидая приложение.", + "wizardMapOpsTitle": "Карта, следы и общая геометрия", + "wizardMapOpsDescription": "Карта приложения напрямую связана с сообщениями, отслеживанием и SAR-слоями, а не является отдельным просмотрщиком.", + "wizardMapOpsFeature1": "Отслеживайте свою позицию, местоположение команды и траектории движения на карте.", + "wizardMapOpsFeature2": "Открывайте рисунки из сообщений, просматривайте их прямо в интерфейсе и удаляйте с карты при необходимости.", + "wizardMapOpsFeature3": "Используйте карты ретрансляторов и общие слои, чтобы понимать покрытие сети в поле.", + "wizardToolsTitle": "Инструменты помимо сообщений", + "wizardToolsDescription": "Здесь есть больше, чем четыре основные вкладки. Приложение также включает настройку, диагностику и необязательные сценарии датчиков.", + "wizardToolsFeature1": "Откройте настройки устройства, чтобы изменить параметры радио, телеметрию, мощность TX и сведения о сопутствующем устройстве.", + "wizardToolsFeature2": "Включите вкладку «Датчики», если нужны панели наблюдаемых датчиков и быстрые действия обновления.", + "wizardToolsFeature3": "Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.", + "contactInSensors": "В датчиках", + "contactAddToSensors": "Добавить в датчики", + "contactSetPath": "Задать путь", + "contactAddedToSensors": "{contactName} добавлен в Датчики", + "contactFailedToClearRoute": "Не удалось очистить маршрут: {error}", + "contactRouteCleared": "Маршрут очищен", + "contactRouteSet": "Маршрут задан: {route}", + "contactFailedToSetRoute": "Не удалось задать маршрут: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "Таймаут ACK", + "opcode": "Опкод", + "payload": "Полезная нагрузка", + "hops": "Хопы", + "hashSize": "Размер хеша", + "pathBytes": "Байты пути", + "selectedPath": "Выбранный путь", + "estimatedTx": "Ожидаемая передача", + "senderToReceipt": "От отправителя до получения", + "receivedCopies": "Полученные копии", + "retryCause": "Причина повтора", + "retryMode": "Режим повтора", + "retryResult": "Результат повтора", + "lastRetry": "Последний повтор", + "rxPackets": "RX-пакеты", + "mesh": "Mesh", + "rate": "Скорость", + "window": "Окно", + "posttxDelay": "Задержка после передачи", + "bandpass": "Полосовой", + "bandpassFilterVoice": "Полосовой фильтр голоса", + "active": "Активно", + "addContact": "Добавить контакт", + "all": "Все", + "clearAllLabel": "Очистить все", + "clearFilters": "Очистить фильтры", + "clearRoute": "Очистить маршрут", + "clearMessages": "Очистить сообщения", + "clearScale": "Очистить масштаб", + "clearDiscoveries": "Очистить обнаружения", + "clearOnlineTraceDatabase": "Очистить базу трассировок", + "clearAllChannels": "Очистить все каналы", + "clearAllContacts": "Очистить все контакты", + "clearChannels": "Очистить каналы", + "clearContacts": "Очистить контакты", + "clearPathOnMaxRetry": "Очистить путь при макс. повторе", + "create": "Создать", + "custom": "Пользовательский", + "defaultValue": "По умолчанию", + "duplicate": "Дублировать", + "editName": "Редактировать имя", + "open": "Открыть", + "paste": "Вставить", + "preview": "Предпросмотр", + "remove": "Удалить", + "rename": "Переименовать", + "resolveAll": "Разрешить все", + "send": "Отправить", + "sendAnyway": "Отправить все равно", + "share": "Поделиться", + "trace": "Трассировка", + "discovery": "Обнаружение", + "discoverRepeaters": "Обнаружить повторители", + "discoverSensors": "Обнаружить датчики", + "repeaterDiscoverySent": "Обнаружение повторителей отправлено", + "sensorDiscoverySent": "Обнаружение датчиков отправлено", + "clearedPendingDiscoveries": "Ожидающие обнаружения очищены.", + "autoDiscovery": "Автоматическое обнаружение", + "enableAutomaticAdding": "Включить автоматическое добавление", + "autoaddRepeaters": "Автодобавление повторителей", + "autoaddRoomServers": "Автодобавление серверов комнат", + "autoaddSensors": "Автодобавление датчиков", + "autoaddUsers": "Автодобавление пользователей", + "overwriteOldestWhenFull": "Перезаписать старейшие при заполнении", + "storage": "Хранилище", + "dangerZone": "Опасная зона", + "profiles": "Профили", + "favourites": "Избранное", + "sensors": "Датчики", + "others": "Другие", + "gpsModule": "Модуль GPS", + "liveTraffic": "Живой трафик", + "repeatersMap": "Карта повторителей", + "blePacketLogs": "Журналы BLE-пакетов", + "onlineTraceDatabase": "База трассировок", + "routePathByteSize": "Размер пути в байтах", + "messageNotifications": "Уведомления о сообщениях", + "sarAlerts": "SAR-оповещения", + "discoveryNotifications": "Уведомления об обнаружении", + "updateNotifications": "Уведомления об обновлениях", + "muteWhileAppIsOpen": "Без звука при открытом приложении", + "disableContacts": "Отключить контакты", + "enableSensorsTab": "Включить вкладку Датчики", + "enableProfiles": "Включить профили", + "nearestRepeaterFallback": "Ближайший повторитель как резерв", + "deleteAllStoredMessageHistory": "Удалить всю историю сообщений", + "messageFontSize": "Размер шрифта сообщений", + "rotateMapWithHeading": "Вращать карту по направлению", + "showMapDebugInfo": "Показать отладочную информацию карты", + "openMapInFullscreen": "Открыть карту на весь экран", + "showSarMarkersLabel": "Показать SAR-маркеры", + "hideRepeatersOnMap": "Скрыть повторители на карте", + "setMapScale": "Установить масштаб карты", + "customMapScaleSaved": "Пользовательский масштаб карты сохранён", + "voiceBitrate": "Битрейт голоса", + "voiceCompressor": "Компрессор голоса", + "voiceLimiter": "Лимитер голоса", + "micAutoGain": "Автоусиление микрофона", + "echoCancellation": "Подавление эха", + "noiseSuppression": "Подавление шума", + "trimSilenceInVoiceMessages": "Обрезать тишину в голосовых сообщениях", + "compressor": "Компрессор", + "limiter": "Лимитер", + "autoGain": "Автоусиление", + "echoCancel": "Эхо", + "noiseSuppress": "Шум", + "silenceTrim": "Тишина", + "maxImageSize": "Максимальный размер изображения", + "imageCompression": "Сжатие изображения", + "grayscale": "Оттенки серого", + "ultraMode": "Режим ультра", + "fastPrivateGpsUpdates": "Быстрые приватные GPS-обновления", + "movementThreshold": "Порог движения", + "fastGpsMovementThreshold": "Порог движения быстрого GPS", + "fastGpsActiveuseInterval": "Интервал активного использования быстрого GPS", + "activeuseUpdateInterval": "Интервал обновления при активном использовании", + "repeatNearbyTraffic": "Повторять ближний трафик", + "relayThroughRepeatersAcrossTheMesh": "Ретранслировать через повторители по сети", + "nearbyOnlyWithoutRepeaterFlooding": "Только рядом, без flood повторителей", + "multihop": "Многохоповый", + "createProfile": "Создать профиль", + "renameProfile": "Переименовать профиль", + "newProfile": "Новый профиль", + "manageProfiles": "Управление профилями", + "enableProfilesToStartManagingThem": "Включите профили, чтобы начать управлять ими.", + "openMessage": "Открыть сообщение", + "jumpToTheRelatedSarMessage": "Перейти к связанному SAR-сообщению", + "removeSarMarker": "Удалить SAR-маркер", + "pleaseSelectADestinationToSendSarMarker": "Выберите назначение для отправки SAR-маркера", + "sarMarkerBroadcastToPublicChannel": "SAR-маркер отправлен в публичный канал", + "sarMarkerSentToRoom": "SAR-маркер отправлен в комнату", + "loadFromGallery": "Загрузить из галереи", + "replaceImage": "Заменить изображение", + "selectFromGallery": "Выбрать из галереи", + "team": "Команда", + "found": "Найдено", + "staging": "Место сбора", + "object": "Объект", + "quiet": "Тихо", + "busy": "Занято", + "searchMessages": "Поиск сообщений", + "sendImageFromGallery": "Отправить изображение из галереи", + "takePhoto": "Сделать фото", + "allMessages": "Все сообщения", + "sendToPublicChannel": "Отправить в публичный канал?", + "selectMarkerTypeAndDestination": "Выберите тип маркера и назначение", + "noDestinationsAvailableLabel": "Нет доступных назначений", + "image": "Изображение", + "format": "Формат", + "dimensions": "Размеры", + "segments": "Сегменты", + "transfers": "Передачи", + "downloadedBy": "Загружено", + "saveDiscoverySettings": "Сохранить настройки обнаружения", + "savePublicInfo": "Сохранить публичную информацию", + "saveRadioSettings": "Сохранить настройки радио", + "savePath": "Сохранить путь", + "wipeDeviceData": "Стереть данные устройства", + "wipeDevice": "Стереть устройство", + "destructiveDeviceActions": "Деструктивные действия устройства.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Выберите пресет или настройте радио вручную.", + "used": "Использовано", + "total": "Всего", + "renameValue": "Переименовать значение", + "customizeFields": "Настроить поля", + "livePreview": "Предпросмотр в реальном времени", + "refreshSchedule": "Расписание обновления", + "noResponse": "Нет ответа", + "refreshing": "Обновление", + "unavailable": "Недоступно", + "pickARelayOrNodeToWatchInSensors": "Выберите реле или узел для наблюдения.", + "publicKeyLabel": "Публичный ключ", + "alreadyInContacts": "Уже в контактах", + "connectToADeviceBeforeAddingContacts": "Подключитесь к устройству перед добавлением контактов", + "fromContacts": "Из контактов", + "onlineOnly": "Только онлайн", + "inBoth": "В обоих", + "source": "Источник", + "allMessagesCleared": "Все сообщения очищены", + "onlineTraceDatabaseCleared": "База трассировок очищена", + "packetLogsCleared": "Журналы пакетов очищены", + "hexDataCopiedToClipboard": "Hex-данные скопированы в буфер", + "developerModeEnabled": "Режим разработчика включён", + "developerModeDisabled": "Режим разработчика отключён", + "clipboardIsEmpty": "Буфер обмена пуст", + "contactImported": "Контакт импортирован", + "contactLinkCopiedToClipboard": "Ссылка на контакт скопирована в буфер", + "failedToExportContact": "Не удалось экспортировать контакт", + "noLogsToExport": "Нет журналов для экспорта", + "exportAsCsv": "Экспортировать как CSV", + "exportAsText": "Экспортировать как текст", + "receivedRfc3339": "Получено (RFC3339)", + "buildTime": "Время сборки", + "downloadUrlNotAvailable": "URL загрузки недоступен", + "cannotOpenDownloadUrl": "Не удаётся открыть URL загрузки", + "updateCheckIsOnlyAvailableOnAndroid": "Проверка обновлений доступна только на Android", + "youAreRunningTheLatestVersion": "Вы используете последнюю версию", + "updateAvailableButDownloadUrlNotFound": "Обновление доступно, но URL загрузки не найден", + "startTictactoe": "Начать Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe недоступно", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: противник неизвестен", + "tictactoeWaitingForStart": "Tic-Tac-Toe: ожидание начала", + "acceptsShareLinks": "Принимает общие ссылки", + "supportsRawHex": "Поддерживает raw hex", + "clipboardfriendly": "Удобно для буфера", + "captured": "Захвачено", + "size": "Размер", + "noCustomChannelsToClear": "Нет пользовательских каналов для очистки.", + "noDeviceContactsToClear": "Нет контактов устройства для очистки.", + "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": "Область региона очищена", + "simpleMode": "Простой режим", + "simpleModeDescription": "Только чат и карта с избранными контактами", + "simpleModeRequiresLockedDestination": "Сначала включите «Зафиксировать назначение» с каналом, чтобы использовать простой режим", + "noFavouriteContacts": "Нет избранных контактов", + "selectDestinationFirst": "Сначала выберите канал, контакт или комнату", + "recipientNoLongerAvailable": "Получатель больше не доступен", + "failedToSendMessage": "Не удалось отправить: {error}", + "tictactoeDirectOnly": "Крестики-нолики работают только в личных сообщениях. Сначала выберите контакт.", + "deviceKeyUnavailable": "Ключ устройства недоступен", + "imageCompressionFailed": "Не удалось сжать изображение", + "imageFragmentationFailed": "Не удалось разделить изображение на фрагменты", + "failedToAnnounceImage": "Не удалось анонсировать изображение", + "noRecipientSelected": "Получатель не выбран", + "imageSendFailed": "Не удалось отправить изображение", + "microphonePermissionRequiredForVoice": "Для голосовых сообщений требуется разрешение на микрофон", + "recordingDiscarded": "Запись отменена", + "publicChannelMediaWarning": "Вы собираетесь отправить {mediaType} в публичный канал. Это не рекомендуется: получить это может любой в mesh-сети. Выберите приватный или тегированный канал, если только это не то, что вы хотите.", + "selectPrivateChannelToShareLocation": "Выберите приватный канал, чтобы поделиться местоположением.", + "stopSharingMyLocation": "Прекратить делиться местоположением", + "shareMyLocation": "Поделиться местоположением", + "stopRecording": "Остановить запись", + "recordVoice": "Записать голосовое сообщение", + "moreActions": "Другие действия", + "noMessagesInFilter": "В этом фильтре нет сообщений", + "noMatchesInFilter": "В этом фильтре нет совпадений", + "syncFailed": "Ошибка синхронизации: {error}", + "invalidLink": "Недействительная ссылка", + "cannotOpenLink": "Не удаётся открыть ссылку", + "failedToOpenLink": "Не удалось открыть ссылку", + "failedToResendMessage": "Не удалось повторно отправить сообщение", + "retryFailed": "Повторная попытка не удалась: {error}", + "deleteDrawingAndMessage": "Удалить рисунок и сообщение", + "lastEchoRelay": "Последнее эхо: ретранслятор", + "lastEchoPath": "Последнее эхо: путь", + "lastEchoBytesReport": "Последнее эхо: отчёт о байтах", + "noGpsCoordinatesAvailable": "Координаты GPS недоступны", + "notASarMarker": "Это не маркер SAR", + "failedToSaveTemplate": "Не удалось сохранить шаблон: {error}", + "drawingNotFound": "Рисунок не найден", + "unknownDrawingType": "Неизвестный тип рисунка", + "customMapMarker": "Пользовательский маркер карты", + "customMapPointLabel": "Точка: {point}", + "mapIdLabel": "ID карты: {id}", + "cannotFetchVoice": "Не удаётся получить голосовое сообщение", + "senderContactUnknown": "Контакт отправителя неизвестен. Сначала синхронизируйте контакты.", + "senderRouteUnknown": "Маршрут отправителя неизвестен. Сначала синхронизируйте контакты/путь.", + "messageTooFar": "Сообщение слишком далеко ({hops} хопов, макс. {maxHops}).", + "senderRouteNoPathResponse": "Маршрут отправителя не ответил на проверку пути. Синхронизируйте контакты/путь и попробуйте снова.", + "senderRouteNoRawResponse": "Маршрут отправителя не ответил на raw-транспортном пути.", + "voiceFetchOverHops": "Получение голоса через {hops} хопов может занять время.", + "voiceReceiveCanceled": "Приём голосового сообщения отменён", + "fetchingMissingVoiceFragments": "Получение недостающих фрагментов голоса", + "receivingVoice": "Приём голоса", + "cannotFetchImage": "Не удаётся получить изображение", + "imageFetchOverHops": "Получение изображения через {hops} хопов может занять время.", + "imageAlreadyBeingReceived": "Изображение уже принимается", + "loadImage": "Загрузить изображение", + "imageFetchFailedToSendRequest": "Не удалось отправить запрос на изображение", + "imageUnavailable": "Изображение сейчас недоступно", + "imageFetchTimedOut": "Время ожидания изображения истекло", + "imageReceiveCanceled": "Приём изображения отменён", + "closeImagePreview": "Закрыть просмотр изображения", + "recordingReleaseToSend": "Запись... отпустите, чтобы отправить", + "sendingVoice": "Отправка голоса...", + "sendLongPressToRecordVoice": "Отправить (удерживайте для записи голоса)", + "discardRecording": "Удалить запись", + "hopCount": "{count, plural, one{{count} хоп} few{{count} хопа} many{{count} хопов} other{{count} хопа}}", + "channelsSectionDescription": "Широковещательные каналы для mesh-трафика поблизости", + "roomsSectionDescription": "Общие пространства для постоянной координации команды", + "contactsSectionDescription": "Люди и устройства в прямой досягаемости", + "noRecentChatters": "Нет недавних собеседников", + "public": "Публичный", + "failedToSendTicTacToeMove": "Не удалось отправить ход в крестики-нолики", + "youWon": "Вы победили", + "opponentWon": "Победил соперник", + "gameDraw": "Ничья", + "yourTurn": "Ваш ход", + "opponentTurn": "Ход соперника", + "pleaseEnterValidCoordinates": "Введите корректные координаты", + "locationNotAvailable": "Местоположение недоступно", + "customCaveMapPoint": "Пользовательская точка карты пещеры", + "mapPoint": "Точка карты", + "fetchingMissingImageFragments": "Получение недостающих фрагментов…", + "loadingImage": "Загрузка…", + "receivingImage": "Приём…", + "tapToLoad": "Нажмите, чтобы загрузить", + "connectDevice": "Подключить устройство", + "chooseTransportSubtitle": "Выберите транспорт: Bluetooth, WiFi или последовательный порт", + "ble": "BLE", + "network": "Сеть", + "serial": "Последовательный порт", + "scan": "Сканировать", + "pressScanToSearchForDevices": "Нажмите «Сканировать», чтобы найти устройства поблизости", + "failedToConnectToDevice": "Не удалось подключиться к {name}", + "unknownDevice": "Неизвестное устройство", + "showingCachedResultsTapRefresh": "Показаны кэшированные результаты. Нажмите «Обновить», чтобы пересканировать.", + "scanningLocalNetworkOnPort": "Сканирование локальной сети на наличие WiFi-устройств MeshCore на порту {port}", + "cancelScanAndAddServer": "Отменить сканирование и добавить сервер", + "addServer": "Добавить сервер", + "scanningProgressIps": "Сканирование... {scanned}/{total} IP-адресов", + "cancelAndEnterManually": "Отменить и ввести вручную", + "noRecentOrDiscoveredServers": "Пока нет недавних или обнаруженных серверов", + "recentlyUsed": "Недавно использованные", + "connecting": "Подключение...", + "discoveredOnThisNetwork": "Обнаружены в этой сети", + "serverNoLongerAvailable": "Сервер {host}:{port} больше недоступен. Выполните сканирование снова, чтобы найти активные серверы.", + "failedToConnectToHost": "Не удалось подключиться к {host}:{port}", + "enterValidIpAddress": "Введите корректный IP-адрес", + "enterValidTcpPort": "Введите корректный TCP-порт", + "ipAddress": "IP-адрес", + "tcpPort": "TCP-порт", + "customServerPort": "Пользовательский порт сервера", + "chat": "Чат", + "restoringPreviousLink": "Восстановление предыдущего подключения", + "noDeviceConnected": "Нет подключённого устройства", + "sendAdvert": "Отправить мой контакт", + "advertMode": "Отправить мой контакт", + "advertModeSubtitle": "Выберите, как далеко должны распространиться ваши контактные данные.", + "floodAdvertSent": "Контакт отправлен по всей mesh-сети", + "directAdvertSent": "Контакт отправлен ближайшим устройствам" +} diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb new file mode 100644 index 0000000..041dccc --- /dev/null +++ b/lib/l10n/app_sl.arb @@ -0,0 +1,1032 @@ +{ + "@@locale": "sl", + "appTitle": "MeshCore SAR", + "messages": "Sporočila", + "contacts": "Stiki", + "map": "Zemljevid", + "settings": "Nastavitve", + "connect": "Poveži", + "disconnect": "Prekini", + "noDevicesFound": "Ni najdenih naprav", + "scanAgain": "Ponovi iskanje", + "deviceNotConnected": "Naprava ni povezana", + "locationPermissionDenied": "Dovoljenje za lokacijo zavrnjeno", + "locationPermissionPermanentlyDenied": "Dovoljenje za lokacijo trajno zavrnjeno. Prosimo, omogočite v Nastavitvah.", + "locationPermissionRequired": "Dovoljenje za lokacijo je potrebno za GPS sledenje in usklajevanje ekipe. Lahko ga omogočite kasneje v Nastavitvah.", + "locationServicesDisabled": "Lokacijske storitve so onemogočene. Prosimo, omogočite jih v Nastavitvah.", + "failedToGetGpsLocation": "Pridobitev GPS lokacije ni uspela", + "failedToAdvertise": "Objava ni uspela: {error}", + "cancelReconnection": "Prekliči ponovno povezovanje", + "general": "Splošno", + "theme": "Tema", + "chooseTheme": "Izberite temo", + "light": "Svetla", + "dark": "Temna", + "blueLightTheme": "Modra svetla tema", + "blueDarkTheme": "Modra temna tema", + "sarRed": "SAR rdeča", + "alertEmergencyMode": "Način opozorila/nujni primer", + "sarGreen": "SAR zelena", + "safeAllClearMode": "Način varno/vse jasno", + "autoSystem": "Samodejno (Sistem)", + "followSystemTheme": "Sledi sistemski temi", + "showRxTxIndicators": "Prikaži RX/TX kazalnike", + "disableMap": "Onemogoči zemljevid", + "language": "Jezik", + "chooseLanguage": "Izberite jezik", + "save": "Shrani", + "cancel": "Prekliči", + "close": "Zapri", + "about": "O aplikaciji", + "appVersion": "Različica 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:", + "technologiesList": "• Flutter za večplatformni razvoj\n• BLE (Bluetooth Low Energy) za mesh omrežje\n• OpenStreetMap za kartografijo\n• Provider za upravljanje stanja\n• SharedPreferences za lokalno shranjevanje", + "moreInfo": "Več informacij", + "packageName": "Ime paketa", + "sampleData": "Vzorčni podatki", + "sampleDataDescription": "Naložite ali počistite vzorčne stike, sporočila kanalov in SAR označevalce za testiranje", + "loadSampleData": "Naloži vzorec", + "clearAllData": "Počisti vse podatke", + "clearAllDataConfirmTitle": "Počisti vse podatke", + "clearAllDataConfirmMessage": "To bo počistilo vse stike in SAR označevalce. Ste prepričani?", + "clear": "Počisti", + "loadedSampleData": "Naloženih {teamCount} članov ekipe, {channelCount} kanalov, {sarCount} SAR označevalcev, {messageCount} sporočil", + "failedToLoadSampleData": "Nalaganje vzorčnih podatkov ni uspelo: {error}", + "allDataCleared": "Vsi podatki počiščeni", + "failedToStartBackgroundTracking": "Zagon sledenja v ozadju ni uspel. Preverite dovoljenja in BLE povezavo.", + "locationBroadcast": "Oddajanje lokacije: {latitude}, {longitude}", + "defaultPinInfo": "Privzeta PIN koda za naprave brez zaslona je 123456. Težave s seznanitvijo? Pozabite napravo Bluetooth v sistemskih nastavitvah.", + "noMessagesYet": "Še ni sporočil", + "pullDownToSync": "Potegnite navzdol za sinhronizacijo", + "deleteContact": "Izbriši stik", + "delete": "Izbriši", + "viewOnMap": "Poglej na zemljevidu", + "refresh": "Osveži", + "resetPath": "Ponastavi pot (preusmeri)", + "copiedToClipboard": "{label} kopirano v odložišče", + "pleaseEnterPassword": "Prosimo, vnesite geslo", + "failedToSyncContacts": "Sinhronizacija stikov ni uspela: {error}", + "loggedInSuccessfully": "Uspešno prijavljen! Čakanje na sporočila sobe...", + "loginFailed": "Prijava ni uspela - nepravilno geslo", + "loggingIn": "Prijavljanje v {roomName}...", + "failedToSendLogin": "Pošiljanje prijave ni uspelo: {error}", + "lowLocationAccuracy": "Nizka natančnost lokacije", + "continue_": "Nadaljuj", + "sendSarMarker": "Pošlji SAR označevalec", + "deleteDrawing": "Izbriši risbo", + "drawingTools": "Orodja za risanje", + "drawLine": "Nariši črto", + "drawLineDesc": "Nariši prosto črto na zemljevidu", + "drawRectangle": "Nariši pravokotnik", + "drawRectangleDesc": "Nariši pravokotno področje na zemljevidu", + "measureDistance": "Meri razdaljo", + "measureDistanceDesc": "Dolg pritisk na dve točki za merjenje", + "clearMeasurement": "Počisti meritev", + "distanceLabel": "Razdalja: {distance}", + "longPressForSecondPoint": "Dolg pritisk za drugo točko", + "longPressToStartMeasurement": "Dolg pritisk za prvo točko", + "longPressToStartNewMeasurement": "Dolg pritisk za novo meritev", + "shareDrawings": "Deli risbe", + "clearAllDrawings": "Počisti vse risbe", + "completeLine": "Dokonč črto", + "broadcastDrawingsToTeam": "Oddaj {count} risb{plural} ekipi", + "removeAllDrawings": "Odstrani vseh {count} risb{plural}", + "deleteAllDrawingsConfirm": "Izbriši vseh {count} risb{plural} z zemljevida?", + "drawing": "Risanje", + "shareDrawingsCount": "Deli {count} risb{plural}", + "showReceivedDrawings": "Prikaži prejete risbe", + "showingAllDrawings": "Prikazujem vse risbe", + "showingOnlyYourDrawings": "Prikazujem samo vaše risbe", + "showSarMarkers": "Prikaži SAR označevalce", + "showingSarMarkers": "Prikazujem SAR označevalce", + "hidingSarMarkers": "Skrivam SAR označevalce", + "clearAll": "Počisti vse", + "publicChannel": "Javni kanal", + "broadcastToAll": "Oddajaj vsem bližnjim vozliščem (začasno)", + "storedPermanently": "Trajno shranjeno v sobi", + "notConnectedToDevice": "Ni povezano z napravo", + "typeYourMessage": "Vnesite svoje sporočilo...", + "quickLocationMarker": "Hitri označevalec lokacije", + "markerType": "Vrsta označevalca", + "sendTo": "Pošlji na", + "noDestinationsAvailable": "Ni dostopnih ciljev.", + "selectDestination": "Izberite cilj...", + "ephemeralBroadcastInfo": "Začasno: Samo oddajanje. Ni shranjeno - vozlišča morajo biti povezana.", + "persistentRoomInfo": "Trajno: Nespremenljivo shranjeno v sobi. Samodejno sinhronizirano in ohranjeno brez povezave.", + "location": "Lokacija", + "fromMap": "Z zemljevida", + "gettingLocation": "Pridobivanje lokacije...", + "locationError": "Napaka lokacije", + "retry": "Poskusi znova", + "refreshLocation": "Osveži lokacijo", + "accuracyMeters": "Natančnost: ±{accuracy}m", + "notesOptional": "Opombe (neobvezno)", + "addAdditionalInformation": "Dodajte dodatne informacije...", + "lowAccuracyWarning": "Natančnost lokacije je ±{accuracy}m. To morda ni dovolj natančno za SAR operacije.\n\nVseeno nadaljuj?", + "loginToRoom": "Prijava v sobo", + "enterPasswordInfo": "Vnesite geslo za dostop do te sobe. Geslo bo shranjeno za prihodnjo uporabo.", + "password": "Geslo", + "enterRoomPassword": "Vnesite geslo sobe", + "loggingInDots": "Prijavljanje...", + "login": "Prijava", + "failedToAddRoom": "Dodajanje sobe v napravo ni uspelo: {error}\n\nSoba morda še ni oglašena.\nPoskusite počakati, da soba odda.", + "direct": "Neposredno", + "flood": "Razpršitev", + "loggedIn": "Prijavljen", + "noGpsData": "Ni GPS podatkov", + "directPingTimeout": "Časovna omejitev neposrednega pinga - ponovni poskus {name} z razprševanjem...", + "pingFailed": "Ping neuspešen do {name} - odgovor ni prejet", + "deleteContactConfirmation": "Ste prepričani, da želite izbrisati \"{name}\"?\n\nTo bo odstranilo stik iz aplikacije in spremljevalne radijske naprave.", + "failedToRemoveContact": "Odstranjevanje stika ni uspelo: {error}", + "type": "Vrsta", + "publicKey": "Javni ključ", + "lastSeen": "Nazadnje viden", + "yes": "Da", + "no": "Ne", + "permissions": "Dovoljenja", + "telemetry": "Telemetrija", + "voltage": "Napetost", + "battery": "Baterija", + "temperature": "Temperatura", + "humidity": "Vlažnost", + "pressure": "Tlak", + "gpsTelemetry": "GPS (Telemetrija)", + "reLoginToRoom": "Ponovna prijava v sobo", + "heading": "Smer", + "elevation": "Nadmorska višina", + "accuracy": "Natančnost", + "distance": "Razdalja", + "bearing": "Azimut", + "direction": "Smer", + "filterMarkers": "Filtriraj označevalce", + "filterMarkersTooltip": "Filtriraj označevalce", + "contactsFilter": "Stiki", + "repeatersFilter": "Ponavljalniki", + "sarMarkers": "SAR označevalci", + "foundPerson": "Najdena oseba", + "fire": "Ogenj", + "stagingArea": "Zbirališče", + "showAll": "Prikaži vse", + "locationUnavailable": "Lokacija ni na voljo", + "ahead": "naravnost", + "degreesRight": "{degrees}° desno", + "degreesLeft": "{degrees}° levo", + "latLonFormat": "Šir: {latitude} Dolž: {longitude}", + "noContactsYet": "Še ni stikov", + "connectToDeviceToLoadContacts": "Povežite se z napravo za nalaganje stikov", + "teamMembers": "Člani ekipe", + "repeaters": "Ponavljalniki", + "rooms": "Sobe", + "channels": "Kanali", + "selectMapLayer": "Izberite sloj zemljevida", + "openStreetMap": "OpenStreetMap", + "openTopoMap": "OpenTopoMap", + "esriSatellite": "ESRI satelit", + "googleHybrid": "Google hibridni zemljevid", + "googleRoadmap": "Google cestni zemljevid", + "googleTerrain": "Google teren", + "dragToPosition": "Povleci na položaj", + "createSarMarker": "Ustvari SAR označevalec", + "compass": "Kompas", + "navigationAndContacts": "Navigacija in stiki", + "sarAlert": "SAR ALARM", + "justNow": "Pravkar", + "minutesAgo": "pred {minutes}m", + "hoursAgo": "pred {hours}h", + "daysAgo": "pred {days}d", + "secondsAgo": "pred {seconds}s", + "sending": "Pošiljanje...", + "sent": "Poslano", + "delivered": "Dostavljeno", + "deliveredWithTime": "Dostavljeno ({time}ms)", + "failed": "Neuspešno", + "broadcast": "Oddajano", + "deliveredToContacts": "Dostavljeno {delivered}/{total} stikom", + "allDelivered": "Vse dostavljeno", + "recipientDetails": "Podrobnosti prejemnikov", + "pending": "V čakanju", + "textCopiedToClipboard": "Besedilo kopirano v odložišče", + "cannotReplySenderMissing": "Ni mogoče odgovoriti: informacije o pošiljatelju manjkajo", + "cannotReplyContactNotFound": "Ni mogoče odgovoriti: stik ni najden", + "copyText": "Kopiraj besedilo", + "saveAsTemplate": "Shrani kot predlogo", + "templateSaved": "Predloga uspešno shranjena", + "templateAlreadyExists": "Predloga s tem emojijem že obstaja", + "deleteMessage": "Izbriši sporočilo", + "deleteMessageConfirmation": "Ali ste prepričani, da želite izbrisati to sporočilo?", + "shareLocation": "Deli lokacijo", + "shareLocationText": "{markerInfo}\n\nKoordinate: {lat}, {lon}\n\nGoogle Maps: {url}", + "sarLocationShare": "SAR Lokacija", + "sarMarkerFoundPerson": "Najdena oseba", + "sarMarkerFire": "Lokacija ognja", + "sarMarkerStagingArea": "Zbirališče", + "sarMarkerObject": "Najden predmet", + "from": "Od", + "coordinates": "Koordinate", + "tapToViewOnMap": "Tapnite za prikaz na zemljevidu", + "radioSettings": "Nastavitve radia", + "frequencyMHz": "Frekvenca (MHz)", + "bandwidth": "Pasovna širina", + "spreadingFactor": "Faktor razširitve", + "codingRate": "Razmerje kodiranja", + "txPowerDbm": "Izhodna moč (dBm)", + "maxPowerDbm": "Največ: {power} dBm", + "you": "Ti", + "exportFailed": "Izvoz ni uspel: {error}", + "importFailed": "Uvoz ni uspel: {error}", + "unknown": "Neznano", + "onlineLayers": "Spletne plasti", + "locationTrail": "Sledilna pot", + "showTrailOnMap": "Prikaži pot na zemljevidu", + "trailVisible": "Pot je vidna na zemljevidu", + "trailHiddenRecording": "Pot je skrita (še se snema)", + "duration": "Trajanje", + "points": "Točke", + "clearTrail": "Počisti pot", + "clearTrailQuestion": "Počisti pot?", + "clearTrailConfirmation": "Ste prepričani, da želite počistiti trenutno sledilno pot? Tega dejanja ni mogoče razveljaviti.", + "noTrailRecorded": "Še ni posnete poti", + "startTrackingToRecord": "Začnite sledenje lokacije za snemanje poti", + "trailControls": "Nadzor poti", + "model": "Model", + "version": "Različica", + "maxContacts": "Maks. stikov", + "maxChannels": "Maks. kanalov", + "publicInfo": "Javne informacije", + "useCurrentLocation": "Uporabi trenutno lokacijo", + "noneUnknown": "Brez/Neznano", + "chatNode": "Vozlišče za klepet", + "repeater": "Ponavljalnik", + "roomChannel": "Soba/Kanal", + "typeNumber": "Tip {number}", + "failedToSave": "Shranjevanje ni uspelo: {error}", + "failedToGetLocation": "Pridobivanje lokacije ni uspelo: {error}", + "sarTemplates": "SAR predloge", + "addTemplate": "Dodaj predlogo", + "editTemplate": "Uredi predlogo", + "deleteTemplate": "Izbriši predlogo", + "templateName": "Ime predloge", + "templateNameHint": "npr. Najdena oseba", + "templateEmoji": "Emoji", + "emojiRequired": "Emoji je obvezen", + "nameRequired": "Ime je obvezno", + "templateDescription": "Opis (neobvezno)", + "templateDescriptionHint": "Dodajte dodatni kontekst...", + "templateColor": "Barva", + "previewFormat": "Predogled (oblika SAR sporočila)", + "importFromClipboard": "Uvozi", + "exportToClipboard": "Izvozi", + "deleteTemplateConfirmation": "Izbrišem predlogo '{name}'?", + "templateAdded": "Predloga dodana", + "templateUpdated": "Predloga posodobljena", + "templateDeleted": "Predloga izbrisana", + "templatesImported": "{count, plural, =0{Ni uvoženih predlog} =1{Uvožena 1 predloga} other{Uvoženih {count} predlog}}", + "templatesExported": "{count, plural, =1{Izvožena 1 predloga v odložišče} other{Izvoženih {count} predlog v odložišče}}", + "resetToDefaults": "Ponastavi na privzeto", + "resetToDefaultsConfirmation": "To bo izbrisalo vse prilagojene predloge in obnovilo 4 privzete predloge. Nadaljevati?", + "reset": "Ponastavi", + "resetComplete": "Predloge ponastavljene na privzeto", + "noTemplates": "Ni razpoložljivih predlog", + "tapAddToCreate": "Tapnite + za ustvarjanje prve predloge", + "ok": "V redu", + "locationPermission": "Dovoljenje za lokacijo", + "checking": "Preverjanje...", + "locationPermissionGrantedAlways": "Odobreno (Vedno)", + "locationPermissionGrantedWhileInUse": "Odobreno (Med uporabo)", + "locationPermissionDeniedTapToRequest": "Zavrnjeno - Tapnite za zahtevo", + "locationPermissionPermanentlyDeniedOpenSettings": "Trajno zavrnjeno - Odpri nastavitve", + "locationPermissionDialogContent": "Dovoljenje za lokacijo je trajno zavrnjeno. Omogočite ga v nastavitvah naprave za uporabo sledenja GPS in deljenja lokacije.", + "openSettings": "Odpri nastavitve", + "locationPermissionGranted": "Dovoljenje za lokacijo odobreno!", + "locationPermissionRequiredForGps": "Dovoljenje za lokacijo je potrebno za sledenje GPS in deljenje lokacije.", + "locationPermissionAlreadyGranted": "Dovoljenje za lokacijo je že odobreno.", + "sarNavyBlue": "SAR Mornarska Modra", + "sarNavyBlueDescription": "Profesionalni/Operativni Način", + "selectRecipient": "Izberi prejemnika", + "broadcastToAllNearby": "Oddajaj vsem v bližini", + "searchRecipients": "Išči prejemnike...", + "noContactsFound": "Ni kontaktov", + "noRoomsFound": "Ni sob", + "noRecipientsAvailable": "Ni na voljo prejemnikov", + "noChannelsFound": "Ni najdenih kanalov", + "newMessage": "Novo sporočilo", + "channel": "Kanal", + "samplePoliceLead": "Vodja Policije", + "sampleDroneOperator": "Operater Drona", + "sampleFirefighterAlpha": "Gasilec", + "sampleMedicCharlie": "Zdravnik", + "sampleCommandDelta": "Poveljstvo", + "sampleFireEngine": "Gasilsko Vozilo", + "sampleAirSupport": "Zračna Podpora", + "sampleBaseCoordinator": "Koordinator Baze", + "channelEmergency": "Nujno", + "channelCoordination": "Koordinacija", + "channelUpdates": "Posodobitve", + "sampleTeamMember": "Vzorčni Član Ekipe", + "sampleScout": "Vzorčni Izvidnik", + "sampleBase": "Vzorčna Baza", + "sampleSearcher": "Vzorčni Iskalec", + "sampleObjectBackpack": " Najden nahrbtnik - modra barva", + "sampleObjectVehicle": " Zapuščeno vozilo - preveriti lastnika", + "sampleObjectCamping": " Odkrita oprema za kampiranje", + "sampleObjectTrailMarker": " Oznaka poti najdena izven poti", + "sampleMsgAllTeamsCheckIn": "Vse ekipe se javite", + "sampleMsgWeatherUpdate": "Posodobitev vremena: Jasno nebo, temp 18°C", + "sampleMsgBaseCamp": "Bazni tabor vzpostavljen na zbirališču", + "sampleMsgTeamAlpha": "Ekipa se premika v sektor 2", + "sampleMsgRadioCheck": "Preverjanje radia - vse postaje odgovorite", + "sampleMsgWaterSupply": "Oskrba z vodo na voljo na kontrolni točki 3", + "sampleMsgTeamBravo": "Ekipa poroča: sektor 1 čist", + "sampleMsgEtaRallyPoint": "Prihod na zbirališče: 15 minut", + "sampleMsgSupplyDrop": "Dostava zalog potrjena za 14:00", + "sampleMsgDroneSurvey": "Nadzor z dronom zaključen - brez najdb", + "sampleMsgTeamCharlie": "Ekipa prosi za okrepitev", + "sampleMsgRadioDiscipline": "Vse enote: vzdrževati radijsko disciplino", + "sampleMsgUrgentMedical": "NUJNO: Potrebna medicinska pomoč v sektorju 4", + "sampleMsgAdultMale": " Odrasel moški, pri zavesti", + "sampleMsgFireSpotted": "Opažen požar - koordinate sledijo", + "sampleMsgSpreadingRapidly": " Hitro se širi!", + "sampleMsgPriorityHelicopter": "PRIORITETA: Potrebna podpora helikopterja", + "sampleMsgMedicalTeamEnRoute": "Medicinska ekipa na poti do vaše lokacije", + "sampleMsgEvacHelicopter": "Helikopter za evakuacijo prihod 10 minut", + "sampleMsgEmergencyResolved": "Nujna situacija rešena – vse čisto", + "sampleMsgEmergencyStagingArea": " Nujno zbirališče", + "sampleMsgEmergencyServices": "Nujne službe obveščene in se odzivajo", + "sampleAlphaTeamLead": "Vodja Ekipe", + "sampleBravoScout": "Izvidnik", + "sampleCharlieMedic": "Zdravnik", + "sampleDeltaNavigator": "Navigator", + "sampleEchoSupport": "Podpora", + "sampleBaseCommand": "Poveljstvo Baze", + "sampleFieldCoordinator": "Terenski Koordinator", + "sampleMedicalTeam": "Medicinska Ekipa", + "mapDrawing": "Risba zemljevida", + "navigateToDrawing": "Navigiraj do risbe", + "copyCoordinates": "Kopiraj koordinate", + "hideFromMap": "Skrij z zemljevida", + "lineDrawing": "Linijska risba", + "rectangleDrawing": "Pravokotna risba", + "manualCoordinates": "Ročne koordinate", + "enterCoordinatesManually": "Ročno vnesite koordinate", + "latitudeLabel": "Geografska širina", + "longitudeLabel": "Geografska dolžina", + "exampleCoordinates": "Primer: 46.0569, 14.5058", + "shareDrawing": "Deli risbo", + "shareWithAllNearbyDevices": "Deli z vsemi bližnjimi napravami", + "shareToRoom": "Deli v Sobo", + "sendToPersistentStorage": "Pošlji v trajno shrambo sobe", + "deleteDrawingConfirm": "Ali ste prepričani, da želite izbrisati to risbo?", + "drawingDeleted": "Risba izbrisana", + "yourDrawingsCount": "Vaše risbe ({count})", + "shared": "Deljeno", + "line": "Črta", + "rectangle": "Pravokotnik", + "updateAvailable": "Na voljo je posodobitev", + "currentVersion": "Trenutna različica", + "latestVersion": "Najnovejša različica", + "downloadUpdate": "Prenesi posodobitev", + "updateLater": "Kasneje", + "cadastralParcels": "Katastrske parcele", + "forestRoads": "Gozdne ceste", + "wmsOverlays": "WMS prekrivanja", + "hikingTrails": "Planinske poti", + "mainRoads": "Glavne ceste", + "houseNumbers": "Hišne številke", + "fireHazardZones": "Požarna ogroženost", + "historicalFires": "Zgodovinski požari", + "firebreaks": "Protipožarne preseke", + "krasFireZones": "Kraška požarišča", + "placeNames": "Zemljepisna imena", + "municipalityBorders": "Občinske meje", + "topographicMap": "Topografska karta 1:25000", + "recentMessages": "Nedavna sporočila", + "addChannel": "Dodaj kanal", + "channelName": "Ime kanala", + "channelNameHint": "npr. Reševalna ekipa Alfa", + "channelSecret": "Geslo kanala", + "channelSecretHint": "Skupno geslo za ta kanal", + "channelSecretHelp": "To geslo mora biti deljeno z vsemi člani ekipe, ki potrebujejo dostop do tega kanala", + "channelTypesInfo": "Hash kanali (#ekipa): Geslo samodejno generirano iz imena. Enako ime = isti kanal na vseh napravah.\n\nZasebni kanali: Uporabite eksplicitno geslo. Samo tisti z geslom se lahko pridružijo.", + "hashChannelInfo": "Hash kanal: Geslo bo samodejno generirano iz imena kanala. Kdorkoli uporabi isto ime, se bo pridružil istemu kanalu.", + "channelNameRequired": "Ime kanala je obvezno", + "channelNameTooLong": "Ime kanala mora imeti največ 31 znakov", + "channelSecretRequired": "Geslo kanala je obvezno", + "channelSecretTooLong": "Geslo kanala mora imeti največ 32 znakov", + "invalidAsciiCharacters": "Dovoljeni so samo ASCII znaki", + "channelCreatedSuccessfully": "Kanal uspešno ustvarjen", + "channelCreationFailed": "Neuspešno ustvarjanje kanala: {error}", + "deleteChannel": "Izbriši kanal", + "deleteChannelConfirmation": "Ali ste prepričani, da želite izbrisati kanal \"{channelName}\"? Tega dejanja ni mogoče razveljaviti.", + "channelDeletedSuccessfully": "Kanal uspešno izbrisan", + "channelDeletionFailed": "Neuspešno brisanje kanala: {error}", + "createChannel": "Ustvari kanal", + "wizardBack": "Nazaj", + "wizardSkip": "Preskoči", + "wizardNext": "Naprej", + "wizardGetStarted": "Začni", + "wizardWelcomeTitle": "Dobrodošli v MeshCore SAR", + "viewWelcomeTutorial": "Ogled vadnice dobrodošlice", + "allTeamContacts": "Vsi stiki ekipe", + "directMessagesInfo": "Neposredna sporočila s potrditvami. Poslano {count} članom ekipe.", + "sarMarkerSentToContacts": "SAR označevalec poslan {count} stikom", + "noContactsAvailable": "Ni razpoložljivih stikov ekipe", + "reply": "Odgovori", + "technicalDetails": "Tehnični podrobnosti", + "messageTechnicalDetails": "Tehnični podrobnosti sporočila", + "linkQuality": "Kakovost povezave", + "delivery": "Dostava", + "status": "Stanje", + "expectedAckTag": "Pričakovana oznaka ACK", + "roundTrip": "Povratna pot", + "retryAttempt": "Poskus ponovnega pošiljanja", + "floodFallback": "Flood rezerva", + "identity": "Identiteta", + "messageId": "ID sporočila", + "sender": "Pošiljatelj", + "senderKey": "Ključ pošiljatelja", + "recipient": "Prejemnik", + "recipientKey": "Ključ prejemnika", + "voice": "Glas", + "voiceId": "ID glasu", + "envelope": "Ovojnica", + "sessionProgress": "Napredek seje", + "complete": "Dokončano", + "rawDump": "Surovi izpis", + "cannotRetryMissingRecipient": "Ponovnega pošiljanja ni mogoče: informacije o prejemniku manjkajo", + "voiceUnavailable": "Glas trenutno ni na voljo", + "requestingVoice": "Zahteva za glasom", + "device": "naprava", + "change": "Spremeni", + "wizardOverviewDescription": "Ta aplikacija združuje sporočanje MeshCore, terenske SAR posodobitve, zemljevide in orodja za napravo na enem mestu.", + "wizardOverviewFeature1": "Pošiljajte neposredna sporočila, objave v sobah in sporočila na kanalih iz glavnega zavihka Sporočila.", + "wizardOverviewFeature2": "Delite SAR označevalce, risbe zemljevida, glasovne posnetke in slike prek mesh omrežja.", + "wizardOverviewFeature3": "Povežite se prek BLE ali TCP in nato upravljajte spremljevalni radio kar iz aplikacije.", + "wizardMessagingTitle": "Sporočanje in terenska poročila", + "wizardMessagingDescription": "Sporočila tukaj niso le navadno besedilo. Aplikacija že podpira več operativnih vrst vsebin in prenosnih tokov.", + "wizardMessagingFeature1": "Pošiljajte neposredna sporočila, objave v sobah in promet na kanalih iz enega urejevalnika.", + "wizardMessagingFeature2": "Ustvarjajte SAR posodobitve in večkrat uporabne SAR predloge za pogosta terenska poročila.", + "wizardMessagingFeature3": "Prenašajte glasovne seje in slike, vmesnik pa prikazuje napredek in oceno časa prenosa.", + "wizardConnectDeviceTitle": "Poveži napravo", + "wizardConnectDeviceDescription": "Povežite svoj MeshCore radio, izberite ime in uporabite radijski prednastavljeni profil, preden nadaljujete.", + "wizardSetupBadge": "Nastavitev", + "wizardOverviewBadge": "Pregled", + "wizardConnectedToDevice": "Povezano z: {deviceName}", + "wizardNoDeviceConnected": "Nobena naprava še ni povezana", + "wizardSkipForNow": "Za zdaj preskoči", + "wizardDeviceNameLabel": "Ime naprave", + "wizardDeviceNameHelp": "To ime se oglašuje drugim uporabnikom MeshCore.", + "wizardConfigRegionLabel": "Konfiguracijska regija", + "wizardConfigRegionHelp": "Uporabi celoten uradni seznam prednastavitev MeshCore. Privzeto je izbran EU/UK (Narrow).", + "wizardPresetNote1": "Prepričajte se, da izbrana prednastavitev ustreza lokalnim radijskim predpisom.", + "wizardPresetNote2": "Seznam ustreza uradnemu viru prednastavitev orodja MeshCore config.", + "wizardPresetNote3": "Za uvajanje ostane privzeto izbran EU/UK (Narrow).", + "wizardSaving": "Shranjujem...", + "wizardSaveAndContinue": "Shrani in nadaljuj", + "wizardEnterDeviceName": "Pred nadaljevanjem vnesite ime naprave.", + "wizardDeviceSetupSaved": "Shranjeno {deviceName} z nastavitvijo {presetName}.", + "wizardNetworkTitle": "Stiki, sobe in repetitorji", + "wizardNetworkDescription": "Zavihek Stiki organizira omrežje, ki ga odkrijete, in poti, ki se jih aplikacija nauči skozi čas.", + "wizardNetworkFeature1": "Na enem seznamu preglejte člane ekipe, repetitorje, sobe, kanale in čakajoče oglase.", + "wizardNetworkFeature2": "Uporabite smart ping, prijavo v sobe, naučene poti in ponastavitev poti, ko povezljivost postane neurejena.", + "wizardNetworkFeature3": "Ustvarjajte kanale in upravljajte omrežne cilje, ne da bi zapustili aplikacijo.", + "wizardMapOpsTitle": "Zemljevid, sledi in deljena geometrija", + "wizardMapOpsDescription": "Zemljevid v aplikaciji je neposredno povezan s sporočanjem, sledenjem in SAR prekrivanji, namesto da bi bil le ločen pregledovalnik.", + "wizardMapOpsFeature1": "Spremljajte svoj položaj, lokacije sotekmovalcev in gibanje sledi na zemljevidu.", + "wizardMapOpsFeature2": "Odprite risbe iz sporočil, jih predoglejte v vrstici in jih po potrebi odstranite z zemljevida.", + "wizardMapOpsFeature3": "Uporabite poglede repetitorjev in deljena prekrivanja za razumevanje dosega omrežja na terenu.", + "wizardToolsTitle": "Orodja onkraj sporočanja", + "wizardToolsDescription": "Tu je več kot le štirje glavni zavihki. Aplikacija vključuje tudi konfiguracijo, diagnostiko in neobvezne senzorske poteke.", + "wizardToolsFeature1": "Odprite nastavitve naprave za spremembo radijskih nastavitev, telemetrije, oddajne moči in podatkov spremljevalne naprave.", + "wizardToolsFeature2": "Omogočite zavihek Senzorji, kadar želite nadzorne plošče in hitra osveževanja spremljanih senzorjev.", + "wizardToolsFeature3": "Uporabite dnevnik paketov, pregled spektra in razvojno diagnostiko pri odpravljanju težav v mesh omrežju.", + "contactInSensors": "V senzorjih", + "contactAddToSensors": "Dodaj v senzorje", + "contactSetPath": "Nastavi pot", + "contactAddedToSensors": "{contactName} dodan v senzorje", + "contactFailedToClearRoute": "Brisanje poti ni uspelo: {error}", + "contactRouteCleared": "Pot izbrisana", + "contactRouteSet": "Pot nastavljena: {route}", + "contactFailedToSetRoute": "Nastavitev poti ni uspela: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "Časovna omejitev ACK", + "opcode": "Opkoda", + "payload": "Vsebina", + "hops": "Skoki", + "hashSize": "Velikost zgoščevalne vrednosti", + "pathBytes": "Bajti poti", + "selectedPath": "Izbrana pot", + "estimatedTx": "Ocenjeni čas oddaje", + "senderToReceipt": "Od pošiljatelja do prejema", + "receivedCopies": "Prejete kopije", + "retryCause": "Razlog ponovitve", + "retryMode": "Način ponovitve", + "retryResult": "Rezultat ponovitve", + "lastRetry": "Zadnja ponovitev", + "rxPackets": "RX paketi", + "mesh": "Mesh", + "rate": "Hitrost", + "window": "Okno", + "posttxDelay": "Zakasnitev po oddaji", + "bandpass": "Pasovno sito", + "bandpassFilterVoice": "Pasovno sito za glas", + "active": "Aktivno", + "addContact": "Dodaj stik", + "all": "Vse", + "clearAllLabel": "Počisti vse", + "clearFilters": "Počisti filtre", + "clearRoute": "Počisti pot", + "clearMessages": "Počisti sporočila", + "clearScale": "Počisti merilo", + "clearDiscoveries": "Počisti odkritja", + "clearOnlineTraceDatabase": "Počisti bazo sledi", + "clearAllChannels": "Počisti vse kanale", + "clearAllContacts": "Počisti vse stike", + "clearChannels": "Počisti kanale", + "clearContacts": "Počisti stike", + "clearPathOnMaxRetry": "Počisti pot ob maks. ponovitvi", + "create": "Ustvari", + "custom": "Po meri", + "defaultValue": "Privzeto", + "duplicate": "Podvoji", + "editName": "Uredi ime", + "open": "Odpri", + "paste": "Prilepi", + "preview": "Predogled", + "remove": "Odstrani", + "rename": "Preimenuj", + "resolveAll": "Razreši vse", + "send": "Pošlji", + "sendAnyway": "Vseeno pošlji", + "share": "Deli", + "trace": "Sledenje", + "discovery": "Odkrivanje", + "discoverRepeaters": "Odkrij posrednike", + "discoverSensors": "Odkrij senzorje", + "repeaterDiscoverySent": "Odkrivanje posrednikov poslano", + "sensorDiscoverySent": "Odkrivanje senzorjev poslano", + "clearedPendingDiscoveries": "Čakalna odkritja počiščena.", + "autoDiscovery": "Samodejno odkrivanje", + "enableAutomaticAdding": "Omogoči samodejno dodajanje", + "autoaddRepeaters": "Samodejno dodaj posrednike", + "autoaddRoomServers": "Samodejno dodaj strežnike sob", + "autoaddSensors": "Samodejno dodaj senzorje", + "autoaddUsers": "Samodejno dodaj uporabnike", + "overwriteOldestWhenFull": "Prepiši najstarejše ob polnem", + "storage": "Shramba", + "dangerZone": "Nevarno območje", + "profiles": "Profili", + "favourites": "Priljubljeni", + "sensors": "Senzorji", + "others": "Ostali", + "gpsModule": "GPS modul", + "liveTraffic": "Promet v živo", + "repeatersMap": "Zemljevid posrednikov", + "blePacketLogs": "Dnevniki BLE paketov", + "onlineTraceDatabase": "Baza sledi", + "routePathByteSize": "Velikost poti v bajtih", + "messageNotifications": "Obvestila o sporočilih", + "sarAlerts": "SAR opozorila", + "discoveryNotifications": "Obvestila o odkritjih", + "updateNotifications": "Obvestila o posodobitvah", + "muteWhileAppIsOpen": "Utišaj ko je aplikacija odprta", + "disableContacts": "Onemogoči stike", + "enableSensorsTab": "Omogoči zavihek Senzorji", + "enableProfiles": "Omogoči profile", + "nearestRepeaterFallback": "Najbližji posrednik kot rezerva", + "deleteAllStoredMessageHistory": "Izbriši vso shranjeno zgodovino sporočil", + "messageFontSize": "Velikost pisave sporočil", + "rotateMapWithHeading": "Vrti zemljevid s smerjo", + "showMapDebugInfo": "Prikaži razhroščevalne info zemljevida", + "openMapInFullscreen": "Odpri zemljevid na cel zaslon", + "showSarMarkersLabel": "Prikaži SAR oznake", + "hideRepeatersOnMap": "Skrij posrednike na zemljevidu", + "setMapScale": "Nastavi merilo zemljevida", + "customMapScaleSaved": "Merilo zemljevida shranjeno", + "voiceBitrate": "Bitna hitrost glasu", + "voiceCompressor": "Kompresor glasu", + "voiceLimiter": "Omejevalnik glasu", + "micAutoGain": "Samodejna ojačitev mikrofona", + "echoCancellation": "Odstranjevanje odmeva", + "noiseSuppression": "Dušenje šuma", + "trimSilenceInVoiceMessages": "Obreži tišino v glasovnih sporočilih", + "compressor": "Kompresor", + "limiter": "Omejevalnik", + "autoGain": "Samodejna ojačitev", + "echoCancel": "Odmev", + "noiseSuppress": "Šum", + "silenceTrim": "Tišina", + "maxImageSize": "Največja velikost slike", + "imageCompression": "Stiskanje slik", + "grayscale": "Sivine", + "ultraMode": "Ultra način", + "fastPrivateGpsUpdates": "Hitri zasebni GPS podatki", + "movementThreshold": "Prag gibanja", + "fastGpsMovementThreshold": "Prag gibanja za hitri GPS", + "fastGpsActiveuseInterval": "Interval uporabe hitrega GPS-a", + "activeuseUpdateInterval": "Interval posodobitve pri aktivni uporabi", + "repeatNearbyTraffic": "Ponavljaj bližnji promet", + "relayThroughRepeatersAcrossTheMesh": "Posreduj skozi posrednike po mreži", + "nearbyOnlyWithoutRepeaterFlooding": "Samo bližnji, brez poplavljanja posrednikov", + "multihop": "Večskočno", + "createProfile": "Ustvari profil", + "renameProfile": "Preimenuj profil", + "newProfile": "Nov profil", + "manageProfiles": "Upravljaj profile", + "enableProfilesToStartManagingThem": "Omogočite profile, da jih začnete upravljati.", + "openMessage": "Odpri sporočilo", + "jumpToTheRelatedSarMessage": "Skoči na povezano SAR sporočilo", + "removeSarMarker": "Odstrani SAR oznako", + "pleaseSelectADestinationToSendSarMarker": "Izberite cilj za pošiljanje SAR oznake", + "sarMarkerBroadcastToPublicChannel": "SAR oznaka poslana na javni kanal", + "sarMarkerSentToRoom": "SAR oznaka poslana v sobo", + "loadFromGallery": "Naloži iz galerije", + "replaceImage": "Zamenjaj sliko", + "selectFromGallery": "Izberi iz galerije", + "team": "Ekipa", + "found": "Najdeno", + "staging": "Zbirno mesto", + "object": "Predmet", + "quiet": "Tiho", + "busy": "Zasedeno", + "searchMessages": "Iskanje sporočil", + "sendImageFromGallery": "Pošlji sliko iz galerije", + "takePhoto": "Posnemi fotografijo", + "allMessages": "Vsa sporočila", + "sendToPublicChannel": "Pošlji na javni kanal?", + "selectMarkerTypeAndDestination": "Izberite vrsto oznake in cilj", + "noDestinationsAvailableLabel": "Ni razpoložljivih ciljev", + "image": "Slika", + "format": "Format", + "dimensions": "Dimenzije", + "segments": "Segmenti", + "transfers": "Prenosi", + "downloadedBy": "Preneseno od", + "saveDiscoverySettings": "Shrani nastavitve odkrivanja", + "savePublicInfo": "Shrani javne podatke", + "saveRadioSettings": "Shrani radijske nastavitve", + "savePath": "Shrani pot", + "wipeDeviceData": "Izbriši podatke naprave", + "wipeDevice": "Izbriši napravo", + "destructiveDeviceActions": "Destruktivna dejanja na napravi.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Izberite prednastavitev ali natančno prilagodite radijske nastavitve.", + "used": "Uporabljeno", + "total": "Skupaj", + "renameValue": "Preimenuj vrednost", + "customizeFields": "Prilagodi polja", + "livePreview": "Predogled v živo", + "refreshSchedule": "Interval osvežitve", + "noResponse": "Ni odgovora", + "refreshing": "Osvežujem", + "unavailable": "Nedostopno", + "pickARelayOrNodeToWatchInSensors": "Izberite posrednika ali vozlišče za opazovanje.", + "publicKeyLabel": "Javni ključ", + "alreadyInContacts": "Že med stiki", + "connectToADeviceBeforeAddingContacts": "Povežite se z napravo pred dodajanjem stikov", + "fromContacts": "Iz stikov", + "onlineOnly": "Samo povezani", + "inBoth": "V obeh", + "source": "Vir", + "allMessagesCleared": "Vsa sporočila počiščena", + "onlineTraceDatabaseCleared": "Baza sledi počiščena", + "packetLogsCleared": "Dnevniki paketov počiščeni", + "hexDataCopiedToClipboard": "Hex podatki kopirani v odložišče", + "developerModeEnabled": "Razvojni način omogočen", + "developerModeDisabled": "Razvojni način onemogočen", + "clipboardIsEmpty": "Odložišče je prazno", + "contactImported": "Stik uvožen", + "contactLinkCopiedToClipboard": "Povezava na stik kopirana v odložišče", + "failedToExportContact": "Izvoz stika ni uspel", + "noLogsToExport": "Ni dnevnikov za izvoz", + "exportAsCsv": "Izvozi kot CSV", + "exportAsText": "Izvozi kot besedilo", + "receivedRfc3339": "Prejeto (RFC3339)", + "buildTime": "Čas gradnje", + "downloadUrlNotAvailable": "URL za prenos ni na voljo", + "cannotOpenDownloadUrl": "URL za prenos ni mogoče odpreti", + "updateCheckIsOnlyAvailableOnAndroid": "Preverjanje posodobitev je na voljo le na Androidu", + "youAreRunningTheLatestVersion": "Uporabljate najnovejšo različico", + "updateAvailableButDownloadUrlNotFound": "Posodobitev na voljo, a URL za prenos ni najden", + "startTictactoe": "Začni Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe ni na voljo", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: nasprotnik neznan", + "tictactoeWaitingForStart": "Tic-Tac-Toe: čakanje na začetek", + "acceptsShareLinks": "Sprejema deljene povezave", + "supportsRawHex": "Podpira surove hex", + "clipboardfriendly": "Primerno za odložišče", + "captured": "Zajeto", + "size": "Velikost", + "noCustomChannelsToClear": "Ni prilagojenih kanalov za brisanje.", + "noDeviceContactsToClear": "Ni stikov naprave za brisanje.", + "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", + "simpleMode": "Enostavni način", + "simpleModeDescription": "Samo klepet in zemljevid s priljubljenimi stiki", + "simpleModeRequiresLockedDestination": "Za uporabo enostavnega načina najprej omogočite »Zakleni cilj« s kanalom", + "noFavouriteContacts": "Ni priljubljenih stikov", + "selectDestinationFirst": "Najprej izberite kanal, stik ali sobo", + "recipientNoLongerAvailable": "Prejemnik ni več na voljo", + "failedToSendMessage": "Pošiljanje ni uspelo: {error}", + "tictactoeDirectOnly": "Križci in krožci delujejo samo v neposrednih sporočilih. Najprej izberite stik.", + "deviceKeyUnavailable": "Ključ naprave ni na voljo", + "imageCompressionFailed": "Stiskanje slike ni uspelo", + "imageFragmentationFailed": "Razdelitev slike ni uspela", + "failedToAnnounceImage": "Najava slike ni uspela", + "noRecipientSelected": "Prejemnik ni izbran", + "imageSendFailed": "Pošiljanje slike ni uspelo", + "microphonePermissionRequiredForVoice": "Za glasovna sporočila je potrebno dovoljenje za mikrofon", + "recordingDiscarded": "Posnetek je zavržen", + "publicChannelMediaWarning": "Pravkar boste poslali {mediaType} v javni kanal. To ni priporočljivo, ker lahko vsebino prejmejo vsi v omrežju mesh. Izberite zaseben ali označen kanal, razen če to res želite.", + "selectPrivateChannelToShareLocation": "Za deljenje lokacije izberite zaseben kanal.", + "stopSharingMyLocation": "Prenehaj deliti mojo lokacijo", + "shareMyLocation": "Deli mojo lokacijo", + "stopRecording": "Ustavi snemanje", + "recordVoice": "Posnemi glasovno sporočilo", + "moreActions": "Več dejanj", + "noMessagesInFilter": "V tem filtru ni sporočil", + "noMatchesInFilter": "V tem filtru ni zadetkov", + "syncFailed": "Sinhronizacija ni uspela: {error}", + "invalidLink": "Neveljavna povezava", + "cannotOpenLink": "Povezave ni mogoče odpreti", + "failedToOpenLink": "Odpiranje povezave ni uspelo", + "failedToResendMessage": "Ponovno pošiljanje sporočila ni uspelo", + "retryFailed": "Ponovni poskus ni uspel: {error}", + "deleteDrawingAndMessage": "Izbriši risbo in sporočilo", + "lastEchoRelay": "Posrednik zadnjega odmeva", + "lastEchoPath": "Pot zadnjega odmeva", + "lastEchoBytesReport": "Poročilo o bajtih zadnjega odmeva", + "noGpsCoordinatesAvailable": "GPS-koordinate niso na voljo", + "notASarMarker": "To ni SAR označevalec", + "failedToSaveTemplate": "Shranjevanje predloge ni uspelo: {error}", + "drawingNotFound": "Risbe ni mogoče najti", + "unknownDrawingType": "Neznana vrsta risbe", + "customMapMarker": "Označevalec karte po meri", + "customMapPointLabel": "Točka: {point}", + "mapIdLabel": "ID karte: {id}", + "cannotFetchVoice": "Glasovnega sporočila ni mogoče prenesti", + "senderContactUnknown": "Stik pošiljatelja ni znan. Najprej sinhronizirajte stike.", + "senderRouteUnknown": "Pot pošiljatelja ni znana. Najprej sinhronizirajte stike/poti.", + "messageTooFar": "Sporočilo je predaleč ({hops} skokov, največ {maxHops}).", + "senderRouteNoPathResponse": "Pot pošiljatelja se ni odzvala na preverjanje poti. Sinhronizirajte stike/poti in poskusite znova.", + "senderRouteNoRawResponse": "Pot pošiljatelja se ni odzvala na neposredni prenosni poti.", + "voiceFetchOverHops": "Prenos glasu prek {hops} skokov lahko traja dlje.", + "voiceReceiveCanceled": "Sprejem glasovnega sporočila je preklican", + "fetchingMissingVoiceFragments": "Pridobivanje manjkajočih glasovnih delov", + "receivingVoice": "Sprejemanje glasu", + "cannotFetchImage": "Slike ni mogoče prenesti", + "imageFetchOverHops": "Prenos slike prek {hops} skokov lahko traja dlje.", + "imageAlreadyBeingReceived": "Slika se že sprejema", + "loadImage": "Naloži sliko", + "imageFetchFailedToSendRequest": "Zahteve za prenos slike ni bilo mogoče poslati", + "imageUnavailable": "Slika trenutno ni na voljo", + "imageFetchTimedOut": "Prenos slike je potekel", + "imageReceiveCanceled": "Sprejem slike je preklican", + "closeImagePreview": "Zapri predogled slike", + "recordingReleaseToSend": "Snemanje ... spustite za pošiljanje glasu", + "sendingVoice": "Pošiljanje glasu ...", + "sendLongPressToRecordVoice": "Pošlji (dolg pritisk za snemanje glasu)", + "discardRecording": "Zavrzi posnetek", + "hopCount": "{count, plural, one{{count} skok} two{{count} skoka} few{{count} skoki} other{{count} skokov}}", + "channelsSectionDescription": "Oddajni kanali za bližnji promet v omrežju mesh", + "roomsSectionDescription": "Skupni prostori za stalno usklajevanje ekipe", + "contactsSectionDescription": "Osebe in naprave, ki jih lahko dosežete neposredno", + "noRecentChatters": "Ni nedavnih klepetalcev", + "public": "Javno", + "failedToSendTicTacToeMove": "Poteze pri križcih in krožcih ni bilo mogoče poslati", + "youWon": "Zmagali ste", + "opponentWon": "Zmagal je nasprotnik", + "gameDraw": "Neodločeno", + "yourTurn": "Vi ste na vrsti", + "opponentTurn": "Na vrsti je nasprotnik", + "pleaseEnterValidCoordinates": "Vnesite veljavne koordinate", + "locationNotAvailable": "Lokacija ni na voljo", + "customCaveMapPoint": "Točka jamske karte po meri", + "mapPoint": "Točka na karti", + "fetchingMissingImageFragments": "Pridobivanje manjkajočih delov …", + "loadingImage": "Nalaganje …", + "receivingImage": "Sprejemanje …", + "tapToLoad": "Tapnite za nalaganje", + "connectDevice": "Poveži napravo", + "chooseTransportSubtitle": "Izberite način povezave: Bluetooth, WiFi ali serijska", + "ble": "BLE", + "network": "Omrežje", + "serial": "Serijsko", + "scan": "Poišči", + "pressScanToSearchForDevices": "Pritisnite Poišči za iskanje naprav v bližini", + "failedToConnectToDevice": "Povezava z napravo {name} ni uspela", + "unknownDevice": "Neznana naprava", + "showingCachedResultsTapRefresh": "Prikazani so predpomnjeni rezultati. Za ponovno iskanje tapnite osveži.", + "scanningLocalNetworkOnPort": "Iskanje naprav MeshCore WiFi v lokalnem omrežju na vratih {port}", + "cancelScanAndAddServer": "Prekliči iskanje in dodaj strežnik", + "addServer": "Dodaj strežnik", + "scanningProgressIps": "Iskanje … {scanned}/{total} IP naslovov", + "cancelAndEnterManually": "Prekliči in vnesi ročno", + "noRecentOrDiscoveredServers": "Še ni nedavnih ali odkritih strežnikov", + "recentlyUsed": "Nedavno uporabljeno", + "connecting": "Povezovanje …", + "discoveredOnThisNetwork": "Odkrito v tem omrežju", + "serverNoLongerAvailable": "Strežnik na {host}:{port} ni več dosegljiv. Ponovite iskanje, da najdete aktivne strežnike.", + "failedToConnectToHost": "Povezava s strežnikom {host}:{port} ni uspela", + "enterValidIpAddress": "Vnesite veljaven IP naslov", + "enterValidTcpPort": "Vnesite veljavna vrata TCP", + "ipAddress": "IP naslov", + "tcpPort": "Vrata TCP", + "customServerPort": "Vrata strežnika po meri", + "chat": "Klepet", + "restoringPreviousLink": "Obnavljanje prejšnje povezave", + "noDeviceConnected": "Ni povezane naprave", + "sendAdvert": "Pošlji moj kontakt", + "advertMode": "Pošlji moj kontakt", + "advertModeSubtitle": "Izberite, kako daleč naj potujejo vaši kontaktni podatki.", + "floodAdvertSent": "Kontakt poslan z razprševanjem po omrežju", + "directAdvertSent": "Kontakt poslan neposredno bližnjim napravam" +} diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb new file mode 100644 index 0000000..3c28a29 --- /dev/null +++ b/lib/l10n/app_tr.arb @@ -0,0 +1,2617 @@ +{ + "@@locale": "tr", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "The application title" + }, + "messages": "Mesajlar", + "@messages": { + "description": "Messages tab label" + }, + "contacts": "Kişiler", + "@contacts": { + "description": "Contacts tab label" + }, + "map": "Harita", + "@map": { + "description": "Map tab label" + }, + "settings": "Ayarlar", + "@settings": { + "description": "Settings screen title" + }, + "connect": "Bağlan", + "@connect": { + "description": "Connect button label" + }, + "disconnect": "Bağlantıyı kes", + "@disconnect": { + "description": "Disconnect button label" + }, + "noDevicesFound": "Cihaz bulunamadı", + "@noDevicesFound": { + "description": "Text shown when no BLE devices are found" + }, + "scanAgain": "Tekrar tara", + "@scanAgain": { + "description": "Button to restart BLE scanning" + }, + "deviceNotConnected": "Cihaz bağlı değil", + "@deviceNotConnected": { + "description": "Error message when device is not connected" + }, + "locationPermissionDenied": "Konum izni reddedildi", + "@locationPermissionDenied": { + "description": "Error when location permission is denied" + }, + "locationPermissionPermanentlyDenied": "Konum izni kalıcı olarak reddedildi. Lütfen Ayarlar bölümünden etkinleştirin.", + "@locationPermissionPermanentlyDenied": { + "description": "Error when location permission is permanently denied" + }, + "locationPermissionRequired": "GPS takibi ve ekip koordinasyonu için konum izni gereklidir. Daha sonra Ayarlar bölümünden etkinleştirebilirsiniz.", + "@locationPermissionRequired": { + "description": "Message when location permission is needed" + }, + "locationServicesDisabled": "Konum servisleri kapalı. Lütfen Ayarlar bölümünden etkinleştirin.", + "@locationServicesDisabled": { + "description": "Error when location services are disabled" + }, + "failedToGetGpsLocation": "GPS konumu alınamadı", + "@failedToGetGpsLocation": { + "description": "Error when GPS location cannot be obtained" + }, + "failedToAdvertise": "Yayın başarısız: {error}", + "@failedToAdvertise": { + "description": "Error message for failed advertisement", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "Yeniden bağlanmayı iptal et", + "@cancelReconnection": { + "description": "Tooltip for cancel reconnection button" + }, + "general": "Genel", + "@general": { + "description": "General settings section header" + }, + "theme": "Tema", + "@theme": { + "description": "Theme setting label" + }, + "chooseTheme": "Tema seç", + "@chooseTheme": { + "description": "Theme selection dialog title" + }, + "light": "Açık", + "@light": { + "description": "Light theme option" + }, + "dark": "Koyu", + "@dark": { + "description": "Dark theme option" + }, + "blueLightTheme": "Mavi açık tema", + "@blueLightTheme": { + "description": "Description for blue light theme" + }, + "blueDarkTheme": "Mavi koyu tema", + "@blueDarkTheme": { + "description": "Description for blue dark theme" + }, + "sarRed": "SAR Kırmızı", + "@sarRed": { + "description": "SAR Red theme option" + }, + "alertEmergencyMode": "Uyarı/Acil durum modu", + "@alertEmergencyMode": { + "description": "Description for SAR Red theme" + }, + "sarGreen": "SAR Yeşil", + "@sarGreen": { + "description": "SAR Green theme option" + }, + "safeAllClearMode": "Güvenli/Tamam modu", + "@safeAllClearMode": { + "description": "Description for SAR Green theme" + }, + "autoSystem": "Otomatik (Sistem)", + "@autoSystem": { + "description": "Auto/System theme option" + }, + "followSystemTheme": "Sistem temasını takip et", + "@followSystemTheme": { + "description": "Description for system theme" + }, + "showRxTxIndicators": "RX/TX göstergelerini göster", + "@showRxTxIndicators": { + "description": "Setting to show RX/TX indicators" + }, + "disableMap": "Haritayı devre dışı bırak", + "@disableMap": { + "description": "Setting to disable the map tab" + }, + "language": "Dil", + "@language": { + "description": "Language setting label" + }, + "chooseLanguage": "Dil seç", + "@chooseLanguage": { + "description": "Language selection dialog title" + }, + "save": "Kaydet", + "@save": { + "description": "Save button label" + }, + "cancel": "İptal", + "@cancel": { + "description": "Cancel button label" + }, + "close": "Kapat", + "@close": { + "description": "Close button label" + }, + "about": "Hakkında", + "@about": { + "description": "About section header" + }, + "appVersion": "Uygulama sürümü", + "@appVersion": { + "description": "App version label" + }, + "aboutMeshCoreSar": "MeshCore SAR hakkında", + "@aboutMeshCoreSar": { + "description": "About dialog title" + }, + "aboutDescription": "Acil durum müdahale ekipleri için tasarlanmış bir Arama ve Kurtarma uygulaması. Özellikler:\n\n• Cihazdan cihaza iletişim için BLE mesh ağı\n• Birden çok katman seçeneğiyle çevrimdışı haritalar\n• Ekip üyelerinin gerçek zamanlı takibi\n• SAR taktik işaretleri (bulunan kişi, yangın, toplanma alanı)\n• Kişi yönetimi ve mesajlaşma\n• Pusula yönüyle GPS takibi\n• Çevrimdışı kullanım için harita karo önbelleği", + "@aboutDescription": { + "description": "About dialog description" + }, + "technologiesUsed": "Kullanılan teknolojiler:", + "@technologiesUsed": { + "description": "Technologies used section title" + }, + "technologiesList": "• Çok platformlu geliştirme için Flutter\n• Mesh ağı için BLE (Bluetooth Low Energy)\n• Haritalama için OpenStreetMap\n• Durum yönetimi için Provider\n• Yerel depolama için SharedPreferences", + "@technologiesList": { + "description": "List of technologies used" + }, + "moreInfo": "Daha fazla bilgi", + "@moreInfo": { + "description": "More info button label" + }, + "packageName": "Paket adı", + "@packageName": { + "description": "Package name label" + }, + "sampleData": "Örnek veriler", + "@sampleData": { + "description": "Sample data section header" + }, + "sampleDataDescription": "Test için örnek kişiler, kanal mesajları ve SAR işaretleri yükleyin veya temizleyin", + "@sampleDataDescription": { + "description": "Sample data section description" + }, + "loadSampleData": "Örnek verileri yükle", + "@loadSampleData": { + "description": "Load sample data button" + }, + "clearAllData": "Tüm verileri temizle", + "@clearAllData": { + "description": "Clear all data button" + }, + "clearAllDataConfirmTitle": "Tüm verileri temizle", + "@clearAllDataConfirmTitle": { + "description": "Clear data confirmation dialog title" + }, + "clearAllDataConfirmMessage": "Bu işlem tüm kişileri ve SAR işaretlerini temizleyecek. Emin misiniz?", + "@clearAllDataConfirmMessage": { + "description": "Clear data confirmation message" + }, + "clear": "Temizle", + "@clear": { + "description": "Clear button label" + }, + "loadedSampleData": "{teamCount} ekip üyesi, {channelCount} kanal, {sarCount} SAR işareti, {messageCount} mesaj yüklendi", + "@loadedSampleData": { + "description": "Success message after loading sample data", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "Örnek veriler yüklenemedi: {error}", + "@failedToLoadSampleData": { + "description": "Error message when sample data fails to load", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "Tüm veriler temizlendi", + "@allDataCleared": { + "description": "Success message after clearing all data" + }, + "failedToStartBackgroundTracking": "Arka plan takibi başlatılamadı. İzinleri ve BLE bağlantısını kontrol edin.", + "@failedToStartBackgroundTracking": { + "description": "Error message when background tracking fails to start" + }, + "locationBroadcast": "Konum yayını: {latitude}, {longitude}", + "@locationBroadcast": { + "description": "Success message for location broadcast", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "Ekranı olmayan cihazlar için varsayılan PIN 123456’dır. Eşleştirme sorunu mu yaşıyorsunuz? Sistem ayarlarından Bluetooth cihazını unutun.", + "@defaultPinInfo": { + "description": "Information about default PIN for pairing" + }, + "noMessagesYet": "Henüz mesaj yok", + "@noMessagesYet": { + "description": "Empty state message when there are no messages" + }, + "pullDownToSync": "Mesajları senkronize etmek için aşağı çekin", + "@pullDownToSync": { + "description": "Instruction to pull down to refresh messages" + }, + "deleteContact": "Kişiyi sil", + "@deleteContact": { + "description": "Delete contact action label" + }, + "delete": "Sil", + "@delete": { + "description": "Delete button label" + }, + "viewOnMap": "Haritada görüntüle", + "@viewOnMap": { + "description": "Action to view contact location on map" + }, + "refresh": "Yenile", + "@refresh": { + "description": "Refresh button label" + }, + "resetPath": "Yolu sıfırla (yeniden yönlendir)", + "@resetPath": { + "description": "Action to reset contact path for re-routing" + }, + "copiedToClipboard": "{label} panoya kopyalandı", + "@copiedToClipboard": { + "description": "Success message when a value is copied to clipboard", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "Lütfen bir parola girin", + "@pleaseEnterPassword": { + "description": "Validation message for empty password field" + }, + "failedToSyncContacts": "Kişiler senkronize edilemedi: {error}", + "@failedToSyncContacts": { + "description": "Error message when contact sync fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "Giriş başarılı! Oda mesajları bekleniyor...", + "@loggedInSuccessfully": { + "description": "Success message after successful room login" + }, + "loginFailed": "Giriş başarısız - yanlış parola", + "@loginFailed": { + "description": "Error message when room login fails" + }, + "loggingIn": "{roomName} odasına giriş yapılıyor...", + "@loggingIn": { + "description": "Status message during room login process", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "Giriş gönderilemedi: {error}", + "@failedToSendLogin": { + "description": "Error message when login command fails to send", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "Düşük konum doğruluğu", + "@lowLocationAccuracy": { + "description": "Warning title for low GPS accuracy" + }, + "continue_": "Devam et", + "@continue_": { + "description": "Continue button label" + }, + "sendSarMarker": "SAR işareti gönder", + "@sendSarMarker": { + "description": "Action to send SAR marker" + }, + "deleteDrawing": "Çizimi sil", + "@deleteDrawing": { + "description": "Action to delete a map drawing" + }, + "drawingTools": "Çizim araçları", + "@drawingTools": { + "description": "Drawing tools section or menu title" + }, + "drawLine": "Çizgi çiz", + "@drawLine": { + "description": "Map drawing mode: line" + }, + "drawLineDesc": "Harita üzerinde serbest çizgi çiz", + "@drawLineDesc": { + "description": "Description for line drawing mode" + }, + "drawRectangle": "Dikdörtgen çiz", + "@drawRectangle": { + "description": "Map drawing mode: rectangle" + }, + "drawRectangleDesc": "Harita üzerinde dikdörtgen alan çiz", + "@drawRectangleDesc": { + "description": "Description for rectangle drawing mode" + }, + "measureDistance": "Mesafe ölç", + "@measureDistance": { + "description": "Map drawing mode: measure distance" + }, + "measureDistanceDesc": "Ölçmek için iki noktaya uzun basın", + "@measureDistanceDesc": { + "description": "Description for distance measurement mode" + }, + "clearMeasurement": "Ölçümü temizle", + "@clearMeasurement": { + "description": "Tooltip to clear measurement" + }, + "distanceLabel": "Mesafe: {distance}", + "@distanceLabel": { + "description": "Label showing measured distance", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "İkinci nokta için uzun basın", + "@longPressForSecondPoint": { + "description": "Instruction when first measurement point is set" + }, + "longPressToStartMeasurement": "İlk noktayı ayarlamak için uzun basın", + "@longPressToStartMeasurement": { + "description": "Instruction to start measurement" + }, + "longPressToStartNewMeasurement": "Yeni ölçüm başlatmak için uzun basın", + "@longPressToStartNewMeasurement": { + "description": "Instruction to restart measurement after completion" + }, + "shareDrawings": "Çizimleri paylaş", + "@shareDrawings": { + "description": "Action to share drawings to network" + }, + "clearAllDrawings": "Tüm çizimleri temizle", + "@clearAllDrawings": { + "description": "Action to clear all local drawings" + }, + "completeLine": "Çizgiyi tamamla", + "@completeLine": { + "description": "Tooltip to complete drawing a line" + }, + "broadcastDrawingsToTeam": "{count} çizim{plural} ekibe yayınla", + "@broadcastDrawingsToTeam": { + "description": "Subtitle showing how many drawings will be broadcast", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "Tüm {count} çizim{plural} kaldır", + "@removeAllDrawings": { + "description": "Subtitle for remove all drawings action", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "Haritadaki tüm {count} çizim{plural} silinsin mi?", + "@deleteAllDrawingsConfirm": { + "description": "Confirmation dialog message for deleting all drawings", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "Çizim", + "@drawing": { + "description": "Generic drawing label" + }, + "shareDrawingsCount": "{count} çizim{plural} paylaş", + "@shareDrawingsCount": { + "description": "Title for share drawings dialog", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "Alınan çizimleri göster", + "@showReceivedDrawings": { + "description": "Toggle to show/hide received drawings from other team members" + }, + "showingAllDrawings": "Tüm çizimler gösteriliyor", + "@showingAllDrawings": { + "description": "Subtitle when received drawings are visible" + }, + "showingOnlyYourDrawings": "Yalnızca sizin çizimleriniz gösteriliyor", + "@showingOnlyYourDrawings": { + "description": "Subtitle when received drawings are hidden" + }, + "showSarMarkers": "SAR işaretlerini göster", + "@showSarMarkers": { + "description": "Toggle to show/hide SAR markers on map" + }, + "showingSarMarkers": "SAR işaretleri gösteriliyor", + "@showingSarMarkers": { + "description": "Subtitle when SAR markers are visible" + }, + "hidingSarMarkers": "SAR işaretleri gizleniyor", + "@hidingSarMarkers": { + "description": "Subtitle when SAR markers are hidden" + }, + "clearAll": "Tümünü temizle", + "@clearAll": { + "description": "Clear all button label" + }, + "publicChannel": "Genel kanal", + "@publicChannel": { + "description": "Public channel option for sharing" + }, + "broadcastToAll": "Yakındaki tüm düğümlere yayınla (geçici)", + "@broadcastToAll": { + "description": "Description for public channel broadcast" + }, + "storedPermanently": "Odada kalıcı olarak saklanır", + "@storedPermanently": { + "description": "Description for room storage permanence" + }, + "notConnectedToDevice": "Cihaza bağlı değil", + "@notConnectedToDevice": { + "description": "Error message when device is not connected for direct messaging" + }, + "typeYourMessage": "Mesajınızı yazın...", + "@typeYourMessage": { + "description": "Placeholder text for message input field" + }, + "quickLocationMarker": "Hızlı konum işareti", + "@quickLocationMarker": { + "description": "Subtitle for SAR marker sheet header" + }, + "markerType": "İşaret türü", + "@markerType": { + "description": "Label for marker type selection section" + }, + "sendTo": "Gönder", + "@sendTo": { + "description": "Label for destination selection section" + }, + "noDestinationsAvailable": "Kullanılabilir hedef yok.", + "@noDestinationsAvailable": { + "description": "Warning when no rooms or channels exist" + }, + "selectDestination": "Hedef seçin...", + "@selectDestination": { + "description": "Placeholder for destination dropdown" + }, + "ephemeralBroadcastInfo": "Geçici: yalnızca havadan yayınlanır. Saklanmaz; düğümlerin çevrimiçi olması gerekir.", + "@ephemeralBroadcastInfo": { + "description": "Information about ephemeral channel broadcasts" + }, + "persistentRoomInfo": "Kalıcı: odada değişmez şekilde saklanır. Otomatik olarak eşitlenir ve çevrimdışı korunur.", + "@persistentRoomInfo": { + "description": "Information about persistent room storage" + }, + "location": "Konum", + "@location": { + "description": "Label for location section" + }, + "fromMap": "Haritadan", + "@fromMap": { + "description": "Badge showing location is from map tap" + }, + "gettingLocation": "Konum alınıyor...", + "@gettingLocation": { + "description": "Loading message while fetching GPS location" + }, + "locationError": "Konum hatası", + "@locationError": { + "description": "Title for location error messages" + }, + "retry": "Tekrar dene", + "@retry": { + "description": "Retry button label" + }, + "refreshLocation": "Konumu yenile", + "@refreshLocation": { + "description": "Tooltip for refresh location button" + }, + "accuracyMeters": "Doğruluk: ±{accuracy}m", + "@accuracyMeters": { + "description": "Display of GPS accuracy in meters", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "Notlar (isteğe bağlı)", + "@notesOptional": { + "description": "Label for optional notes field" + }, + "addAdditionalInformation": "Ek bilgi ekleyin...", + "@addAdditionalInformation": { + "description": "Placeholder for notes field" + }, + "lowAccuracyWarning": "Konum doğruluğu ±{accuracy}m. Bu, SAR operasyonları için yeterince doğru olmayabilir.\n\nYine de devam edilsin mi?", + "@lowAccuracyWarning": { + "description": "Warning dialog content for low GPS accuracy", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "Odaya giriş yap", + "@loginToRoom": { + "description": "Title for room login dialog" + }, + "enterPasswordInfo": "Bu odaya erişmek için parolayı girin. Parola daha sonra kullanılmak üzere kaydedilecektir.", + "@enterPasswordInfo": { + "description": "Information about room password" + }, + "password": "Parola", + "@password": { + "description": "Password field label" + }, + "enterRoomPassword": "Oda parolasını girin", + "@enterRoomPassword": { + "description": "Password field hint" + }, + "loggingInDots": "Giriş yapılıyor...", + "@loggingInDots": { + "description": "Button text while logging in" + }, + "login": "Giriş yap", + "@login": { + "description": "Login button label" + }, + "failedToAddRoom": "Oda cihaza eklenemedi: {error}\n\nOda henüz yayın yapmamış olabilir.\nYayın yapmasını beklemeyi deneyin.", + "@failedToAddRoom": { + "description": "Error message when adding room fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "Doğrudan", + "@direct": { + "description": "Direct routing indicator" + }, + "flood": "Yayılım", + "@flood": { + "description": "Flood routing indicator" + }, + "loggedIn": "Giriş yapıldı", + "@loggedIn": { + "description": "Logged in status badge" + }, + "noGpsData": "GPS verisi yok", + "@noGpsData": { + "description": "Message when GPS data is not available" + }, + "distance": "Mesafe", + "@distance": { + "description": "Distance label" + }, + "directPingTimeout": "Doğrudan ping zaman aşımına uğradı - {name} flooding ile yeniden deneniyor...", + "@directPingTimeout": { + "description": "Warning when direct ping times out", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "{name} için ping başarısız - yanıt alınamadı", + "@pingFailed": { + "description": "Error message when ping fails", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "\"{name}\" kişisini silmek istediğinizden emin misiniz?\n\nBu işlem kişiyi hem uygulamadan hem de bağlı radyo cihazından kaldıracaktır.", + "@deleteContactConfirmation": { + "description": "Confirmation message for deleting contact", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "Kişi kaldırılamadı: {error}", + "@failedToRemoveContact": { + "description": "Error message when contact removal fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "Tür", + "@type": { + "description": "Contact type label" + }, + "publicKey": "Genel anahtar", + "@publicKey": { + "description": "Public key label" + }, + "lastSeen": "Son görülme", + "@lastSeen": { + "description": "Last seen label" + }, + "yes": "Evet", + "@yes": { + "description": "Yes answer" + }, + "no": "Hayır", + "@no": { + "description": "No answer" + }, + "permissions": "İzinler", + "@permissions": { + "description": "Permissions label" + }, + "telemetry": "Telemetri", + "@telemetry": { + "description": "Telemetry section header" + }, + "voltage": "Voltaj", + "@voltage": { + "description": "Voltage label" + }, + "battery": "Pil", + "@battery": { + "description": "Battery label" + }, + "temperature": "Sıcaklık", + "@temperature": { + "description": "Temperature label" + }, + "humidity": "Nem", + "@humidity": { + "description": "Humidity label" + }, + "pressure": "Basınç", + "@pressure": { + "description": "Pressure label" + }, + "gpsTelemetry": "GPS (telemetri)", + "@gpsTelemetry": { + "description": "GPS from telemetry label" + }, + "reLoginToRoom": "Odaya yeniden giriş yap", + "@reLoginToRoom": { + "description": "Button to re-login to room" + }, + "heading": "Yön", + "@heading": { + "description": "Compass heading label" + }, + "elevation": "Rakım", + "@elevation": { + "description": "Elevation/altitude label" + }, + "accuracy": "Doğruluk", + "@accuracy": { + "description": "GPS accuracy label" + }, + "bearing": "İstikamet", + "@bearing": { + "description": "Bearing label in compass" + }, + "direction": "Yön", + "@direction": { + "description": "Direction label in compass" + }, + "filterMarkers": "İşaretleri filtrele", + "@filterMarkers": { + "description": "Title for filter markers dialog" + }, + "filterMarkersTooltip": "İşaretleri filtrele", + "@filterMarkersTooltip": { + "description": "Tooltip for filter button" + }, + "contactsFilter": "Kişiler", + "@contactsFilter": { + "description": "Filter option for contacts" + }, + "repeatersFilter": "Tekrarlayıcılar", + "@repeatersFilter": { + "description": "Filter option for repeaters" + }, + "sarMarkers": "SAR işaretleri", + "@sarMarkers": { + "description": "SAR markers section header" + }, + "foundPerson": "Bulunan kişi", + "@foundPerson": { + "description": "Found person SAR marker type" + }, + "fire": "Yangın", + "@fire": { + "description": "Fire SAR marker type" + }, + "stagingArea": "Toplanma alanı", + "@stagingArea": { + "description": "Staging area SAR marker type" + }, + "showAll": "Tümünü göster", + "@showAll": { + "description": "Button to show all filters" + }, + "locationUnavailable": "Konum kullanılamıyor", + "@locationUnavailable": { + "description": "Message when GPS location is unavailable" + }, + "ahead": "ileride", + "@ahead": { + "description": "Relative bearing direction - ahead" + }, + "degreesRight": "{degrees}° sağda", + "@degreesRight": { + "description": "Relative bearing direction - right", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "{degrees}° solda", + "@degreesLeft": { + "description": "Relative bearing direction - left", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "Enl: {latitude} Boy: {longitude}", + "@latLonFormat": { + "description": "Latitude and longitude display format", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "Henüz kişi yok", + "@noContactsYet": { + "description": "Empty state message when there are no contacts" + }, + "connectToDeviceToLoadContacts": "Kişileri yüklemek için bir cihaza bağlanın", + "@connectToDeviceToLoadContacts": { + "description": "Instruction to connect device to load contacts" + }, + "teamMembers": "Ekip üyeleri", + "@teamMembers": { + "description": "Section header for team members (chat contacts)" + }, + "repeaters": "Tekrarlayıcılar", + "@repeaters": { + "description": "Section header for repeater nodes" + }, + "rooms": "Odalar", + "@rooms": { + "description": "Section header for rooms" + }, + "channels": "Kanallar", + "@channels": { + "description": "Section header for broadcast channels" + }, + "selectMapLayer": "Harita katmanını seç", + "@selectMapLayer": { + "description": "Title for map layer selection dialog" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "OpenStreetMap layer name" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "OpenTopoMap layer name" + }, + "esriSatellite": "ESRI Uydu", + "@esriSatellite": { + "description": "ESRI Satellite imagery layer name" + }, + "googleHybrid": "Google Hibrit", + "@googleHybrid": { + "description": "Google Hybrid layer name (satellite + labels)" + }, + "googleRoadmap": "Google Yol Haritası", + "@googleRoadmap": { + "description": "Google Roadmap layer name (street map)" + }, + "googleTerrain": "Google Arazi", + "@googleTerrain": { + "description": "Google Terrain layer name (topographic)" + }, + "dragToPosition": "Konuma sürükle", + "@dragToPosition": { + "description": "Label when dragging a pin on map" + }, + "createSarMarker": "SAR işareti oluştur", + "@createSarMarker": { + "description": "Label for creating SAR marker from pin" + }, + "compass": "Pusula", + "@compass": { + "description": "Compass title in detailed compass dialog" + }, + "navigationAndContacts": "Navigasyon ve kişiler", + "@navigationAndContacts": { + "description": "Subtitle for compass dialog" + }, + "sarAlert": "SAR ALARMI", + "@sarAlert": { + "description": "Label for SAR alert badge on messages" + }, + "textCopiedToClipboard": "Metin panoya kopyalandı", + "@textCopiedToClipboard": { + "description": "Success message when text is copied" + }, + "cannotReplySenderMissing": "Yanıtlanamıyor: gönderen bilgisi eksik", + "@cannotReplySenderMissing": { + "description": "Error when sender info is missing for reply" + }, + "cannotReplyContactNotFound": "Yanıtlanamıyor: kişi bulunamadı", + "@cannotReplyContactNotFound": { + "description": "Error when contact not found for reply" + }, + "copyText": "Metni kopyala", + "@copyText": { + "description": "Option to copy message text to clipboard" + }, + "saveAsTemplate": "Şablon olarak kaydet", + "@saveAsTemplate": { + "description": "Option to save SAR message as a reusable template" + }, + "templateSaved": "Şablon başarıyla kaydedildi", + "@templateSaved": { + "description": "Success message when SAR template is saved" + }, + "templateAlreadyExists": "Bu emoji ile bir şablon zaten var", + "@templateAlreadyExists": { + "description": "Error message when trying to save duplicate template" + }, + "deleteMessage": "Mesajı sil", + "@deleteMessage": { + "description": "Dialog title for deleting a message" + }, + "deleteMessageConfirmation": "Bu mesajı silmek istediğinizden emin misiniz?", + "@deleteMessageConfirmation": { + "description": "Confirmation text for message deletion" + }, + "shareLocation": "Konumu paylaş", + "@shareLocation": { + "description": "Option to share SAR marker location" + }, + "shareLocationText": "{markerInfo}\n\nKoordinatlar: {lat}, {lon}\n\nGoogle Maps: {url}", + "@shareLocationText": { + "description": "Formatted text for sharing SAR marker location", + "placeholders": { + "markerInfo": { + "type": "String" + }, + "lat": { + "type": "String" + }, + "lon": { + "type": "String" + }, + "url": { + "type": "String" + } + } + }, + "sarLocationShare": "SAR konumu", + "@sarLocationShare": { + "description": "Subject line when sharing SAR marker location" + }, + "justNow": "Az önce", + "@justNow": { + "description": "Time indicator for very recent activity" + }, + "minutesAgo": "{minutes} dk önce", + "@minutesAgo": { + "description": "Time indicator for minutes ago", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "{hours} sa önce", + "@hoursAgo": { + "description": "Time indicator for hours ago", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "{days} g önce", + "@daysAgo": { + "description": "Time indicator for days ago", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "{seconds} sn önce", + "@secondsAgo": { + "description": "Time indicator for seconds ago", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "Gönderiliyor...", + "@sending": { + "description": "Delivery status: sending" + }, + "sent": "Gönderildi", + "@sent": { + "description": "Delivery status: sent" + }, + "delivered": "Teslim edildi", + "@delivered": { + "description": "Delivery status: delivered" + }, + "deliveredWithTime": "Teslim edildi ({time}ms)", + "@deliveredWithTime": { + "description": "Delivery status with round-trip time", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "Başarısız", + "@failed": { + "description": "Delivery status: failed" + }, + "broadcast": "Yayın", + "@broadcast": { + "description": "Delivery status for channel messages (no echoes yet)" + }, + "deliveredToContacts": "{delivered}/{total} kişiye teslim edildi", + "@deliveredToContacts": { + "description": "Grouped message delivery count", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "Hepsi teslim edildi", + "@allDelivered": { + "description": "Status when all recipients received the message" + }, + "recipientDetails": "Alıcı ayrıntıları", + "@recipientDetails": { + "description": "Header for expandable recipient list" + }, + "pending": "Bekliyor", + "@pending": { + "description": "Delivery status: pending/waiting" + }, + "sarMarkerFoundPerson": "Bulunan kişi", + "@sarMarkerFoundPerson": { + "description": "SAR marker type: found person" + }, + "sarMarkerFire": "Yangın konumu", + "@sarMarkerFire": { + "description": "SAR marker type: fire" + }, + "sarMarkerStagingArea": "Toplanma alanı", + "@sarMarkerStagingArea": { + "description": "SAR marker type: staging area" + }, + "sarMarkerObject": "Bulunan nesne", + "@sarMarkerObject": { + "description": "SAR marker type: object" + }, + "from": "Kimden", + "@from": { + "description": "Sender label in notifications" + }, + "coordinates": "Koordinatlar", + "@coordinates": { + "description": "Coordinates label" + }, + "tapToViewOnMap": "Haritada görüntülemek için dokunun", + "@tapToViewOnMap": { + "description": "Notification action text" + }, + "radioSettings": "Radyo ayarları", + "@radioSettings": { + "description": "Section title for radio settings" + }, + "frequencyMHz": "Frekans (MHz)", + "@frequencyMHz": { + "description": "Label for radio frequency field" + }, + "bandwidth": "Bant genişliği", + "@bandwidth": { + "description": "Label for bandwidth dropdown" + }, + "spreadingFactor": "Yayılma faktörü", + "@spreadingFactor": { + "description": "Label for spreading factor dropdown" + }, + "codingRate": "Kodlama oranı", + "@codingRate": { + "description": "Label for coding rate dropdown" + }, + "txPowerDbm": "TX gücü (dBm)", + "@txPowerDbm": { + "description": "Label for TX power field" + }, + "maxPowerDbm": "Maks: {power} dBm", + "@maxPowerDbm": { + "description": "Helper text showing maximum TX power", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "Siz", + "@you": { + "description": "Label for the current user in message bubbles" + }, + "exportFailed": "Dışa aktarma başarısız: {error}", + "@exportFailed": { + "description": "Error message when export fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "İçe aktarma başarısız: {error}", + "@importFailed": { + "description": "Error message when import fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "Bilinmiyor", + "@unknown": { + "description": "Unknown value label" + }, + "onlineLayers": "Çevrimiçi katmanlar", + "@onlineLayers": { + "description": "Section header for online map layers" + }, + "locationTrail": "Konum izi", + "@locationTrail": { + "description": "Location trail title" + }, + "showTrailOnMap": "İzi haritada göster", + "@showTrailOnMap": { + "description": "Toggle to show/hide trail on map" + }, + "trailVisible": "İz haritada görünüyor", + "@trailVisible": { + "description": "Trail visibility status - visible" + }, + "trailHiddenRecording": "İz gizli (kayıt devam ediyor)", + "@trailHiddenRecording": { + "description": "Trail visibility status - hidden but recording" + }, + "duration": "Süre", + "@duration": { + "description": "Duration label" + }, + "points": "Noktalar", + "@points": { + "description": "Trail points count label" + }, + "clearTrail": "İzi temizle", + "@clearTrail": { + "description": "Button to clear location trail" + }, + "clearTrailQuestion": "İz temizlensin mi?", + "@clearTrailQuestion": { + "description": "Confirmation dialog title" + }, + "clearTrailConfirmation": "Geçerli konum izini temizlemek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "@clearTrailConfirmation": { + "description": "Confirmation dialog message" + }, + "noTrailRecorded": "Henüz iz kaydedilmedi", + "@noTrailRecorded": { + "description": "Message when no trail exists" + }, + "startTrackingToRecord": "İzi kaydetmek için konum takibini başlatın", + "@startTrackingToRecord": { + "description": "Instructions to start trail recording" + }, + "trailControls": "İz kontrolleri", + "@trailControls": { + "description": "Trail controls tooltip" + }, + "model": "Model", + "@model": { + "description": "Device model label" + }, + "version": "Sürüm", + "@version": { + "description": "Version label" + }, + "maxContacts": "Maks kişi", + "@maxContacts": { + "description": "Maximum contacts capacity label" + }, + "maxChannels": "Maks kanal", + "@maxChannels": { + "description": "Maximum channels capacity label" + }, + "publicInfo": "Genel bilgiler", + "@publicInfo": { + "description": "Public information section header" + }, + "useCurrentLocation": "Geçerli konumu kullan", + "@useCurrentLocation": { + "description": "Tooltip for use current location button" + }, + "noneUnknown": "Yok/Bilinmiyor", + "@noneUnknown": { + "description": "Device type: none or unknown" + }, + "chatNode": "Sohbet düğümü", + "@chatNode": { + "description": "Device type: chat node" + }, + "repeater": "Tekrarlayıcı", + "@repeater": { + "description": "Device type: repeater" + }, + "roomChannel": "Oda/Kanal", + "@roomChannel": { + "description": "Device type: room or channel" + }, + "typeNumber": "Tür {number}", + "@typeNumber": { + "description": "Generic device type with number", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "Kaydetme başarısız: {error}", + "@failedToSave": { + "description": "Generic error message for save failures", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "Konum alınamadı: {error}", + "@failedToGetLocation": { + "description": "Error message when getting location fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "SAR şablonları", + "@sarTemplates": { + "description": "SAR templates menu title" + }, + "addTemplate": "Şablon ekle", + "@addTemplate": { + "description": "Button to add new SAR template" + }, + "editTemplate": "Şablonu düzenle", + "@editTemplate": { + "description": "Dialog title for editing template" + }, + "deleteTemplate": "Şablonu sil", + "@deleteTemplate": { + "description": "Action to delete template" + }, + "templateName": "Şablon adı", + "@templateName": { + "description": "Label for template name field" + }, + "templateNameHint": "örn. Bulunan kişi", + "@templateNameHint": { + "description": "Hint text for template name" + }, + "templateEmoji": "Emoji", + "@templateEmoji": { + "description": "Label for template emoji field" + }, + "emojiRequired": "Emoji gerekli", + "@emojiRequired": { + "description": "Validation error when emoji field is empty" + }, + "nameRequired": "Ad gerekli", + "@nameRequired": { + "description": "Validation error when name field is empty" + }, + "templateDescription": "Açıklama (isteğe bağlı)", + "@templateDescription": { + "description": "Label for template description field" + }, + "templateDescriptionHint": "Ek bağlam ekleyin...", + "@templateDescriptionHint": { + "description": "Hint text for template description" + }, + "templateColor": "Renk", + "@templateColor": { + "description": "Label for template color picker" + }, + "previewFormat": "Önizleme (SAR mesaj biçimi)", + "@previewFormat": { + "description": "Label for format preview" + }, + "importFromClipboard": "İçe aktar", + "@importFromClipboard": { + "description": "Button to import templates from clipboard" + }, + "exportToClipboard": "Dışa aktar", + "@exportToClipboard": { + "description": "Button to export templates to clipboard" + }, + "deleteTemplateConfirmation": "“{name}” şablonu silinsin mi?", + "@deleteTemplateConfirmation": { + "description": "Confirmation message for template deletion", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "templateAdded": "Şablon eklendi", + "@templateAdded": { + "description": "Success message when template is added" + }, + "templateUpdated": "Şablon güncellendi", + "@templateUpdated": { + "description": "Success message when template is updated" + }, + "templateDeleted": "Şablon silindi", + "@templateDeleted": { + "description": "Success message when template is deleted" + }, + "templatesImported": "{count, plural, =0{Şablon içe aktarılmadı} =1{1 şablon içe aktarıldı} other{{count} şablon içe aktarıldı}}", + "@templatesImported": { + "description": "Success message after importing templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "templatesExported": "{count, plural, =1{1 şablon panoya aktarıldı} other{{count} şablon panoya aktarıldı}}", + "@templatesExported": { + "description": "Success message after exporting templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "resetToDefaults": "Varsayılanlara sıfırla", + "@resetToDefaults": { + "description": "Action to reset templates to defaults" + }, + "resetToDefaultsConfirmation": "Bu işlem tüm özel şablonları silecek ve 4 varsayılan şablonu geri yükleyecek. Devam edilsin mi?", + "@resetToDefaultsConfirmation": { + "description": "Confirmation message for reset to defaults" + }, + "reset": "Sıfırla", + "@reset": { + "description": "Reset button label" + }, + "resetComplete": "Şablonlar varsayılana sıfırlandı", + "@resetComplete": { + "description": "Success message after reset" + }, + "noTemplates": "Kullanılabilir şablon yok", + "@noTemplates": { + "description": "Message when no templates exist" + }, + "tapAddToCreate": "İlk şablonunuzu oluşturmak için + işaretine dokunun", + "@tapAddToCreate": { + "description": "Helper text when no templates exist" + }, + "ok": "Tamam", + "@ok": { + "description": "OK button label" + }, + "locationPermission": "Konum izni", + "@locationPermission": { + "description": "Location permission label" + }, + "checking": "Kontrol ediliyor...", + "@checking": { + "description": "Loading state indicator" + }, + "locationPermissionGrantedAlways": "Verildi (Her zaman)", + "@locationPermissionGrantedAlways": { + "description": "Location permission status: granted always" + }, + "locationPermissionGrantedWhileInUse": "Verildi (Kullanım sırasında)", + "@locationPermissionGrantedWhileInUse": { + "description": "Location permission status: granted while in use" + }, + "locationPermissionDeniedTapToRequest": "Reddedildi - istemek için dokunun", + "@locationPermissionDeniedTapToRequest": { + "description": "Location permission status: denied, user can request" + }, + "locationPermissionPermanentlyDeniedOpenSettings": "Kalıcı olarak reddedildi - ayarları aç", + "@locationPermissionPermanentlyDeniedOpenSettings": { + "description": "Location permission status: permanently denied" + }, + "locationPermissionDialogContent": "Konum izni kalıcı olarak reddedildi. GPS takibi ve konum paylaşımını kullanmak için lütfen cihaz ayarlarından etkinleştirin.", + "@locationPermissionDialogContent": { + "description": "Content for location permission dialog when permanently denied" + }, + "openSettings": "Ayarları aç", + "@openSettings": { + "description": "Button to open device settings" + }, + "locationPermissionGranted": "Konum izni verildi!", + "@locationPermissionGranted": { + "description": "Success message when location permission is granted" + }, + "locationPermissionRequiredForGps": "GPS takibi ve konum paylaşımı için konum izni gereklidir.", + "@locationPermissionRequiredForGps": { + "description": "Info message about location permission requirement" + }, + "locationPermissionAlreadyGranted": "Konum izni zaten verilmiş.", + "@locationPermissionAlreadyGranted": { + "description": "Info message when permission is already granted" + }, + "sarNavyBlue": "SAR Lacivert", + "@sarNavyBlue": { + "description": "SAR Navy Blue theme name" + }, + "sarNavyBlueDescription": "Profesyonel/Operasyon modu", + "@sarNavyBlueDescription": { + "description": "Description for SAR Navy Blue theme" + }, + "selectRecipient": "Alıcı seç", + "@selectRecipient": { + "description": "Title for recipient selector sheet" + }, + "broadcastToAllNearby": "Yakındaki herkese yayınla", + "@broadcastToAllNearby": { + "description": "Subtitle for public channel option" + }, + "searchRecipients": "Alıcı ara...", + "@searchRecipients": { + "description": "Placeholder text for recipient search field" + }, + "noContactsFound": "Kişi bulunamadı", + "@noContactsFound": { + "description": "Message when no contacts match search" + }, + "noRoomsFound": "Oda bulunamadı", + "@noRoomsFound": { + "description": "Message when no rooms match search" + }, + "noRecipientsAvailable": "Kullanılabilir alıcı yok", + "@noRecipientsAvailable": { + "description": "Message when no recipients exist (contacts, rooms, or channels)" + }, + "noChannelsFound": "Kanal bulunamadı", + "@noChannelsFound": { + "description": "Message when no channels match the search" + }, + "newMessage": "Yeni mesaj", + "@newMessage": { + "description": "Notification title for new message" + }, + "channel": "Kanal", + "@channel": { + "description": "Channel label in notifications" + }, + "samplePoliceLead": "Polis lideri", + "@samplePoliceLead": { + "description": "Sample team member name" + }, + "sampleDroneOperator": "Drone operatörü", + "@sampleDroneOperator": { + "description": "Sample team member name" + }, + "sampleFirefighterAlpha": "İtfaiyeci", + "@sampleFirefighterAlpha": { + "description": "Sample team member name" + }, + "sampleMedicCharlie": "Sağlık görevlisi", + "@sampleMedicCharlie": { + "description": "Sample team member name" + }, + "sampleCommandDelta": "Komuta", + "@sampleCommandDelta": { + "description": "Sample team member name" + }, + "sampleFireEngine": "İtfaiye aracı", + "@sampleFireEngine": { + "description": "Sample team member name" + }, + "sampleAirSupport": "Hava desteği", + "@sampleAirSupport": { + "description": "Sample team member name" + }, + "sampleBaseCoordinator": "Üs koordinatörü", + "@sampleBaseCoordinator": { + "description": "Sample team member name" + }, + "channelEmergency": "Acil durum", + "@channelEmergency": { + "description": "Emergency channel name" + }, + "channelCoordination": "Koordinasyon", + "@channelCoordination": { + "description": "Coordination channel name" + }, + "channelUpdates": "Güncellemeler", + "@channelUpdates": { + "description": "Updates channel name" + }, + "sampleTeamMember": "Örnek ekip üyesi", + "@sampleTeamMember": { + "description": "Sample sender name" + }, + "sampleScout": "Örnek keşifçi", + "@sampleScout": { + "description": "Sample sender name" + }, + "sampleBase": "Örnek üs", + "@sampleBase": { + "description": "Sample sender name" + }, + "sampleSearcher": "Örnek arayıcı", + "@sampleSearcher": { + "description": "Sample sender name" + }, + "sampleObjectBackpack": " Sırt çantası bulundu - mavi renk", + "@sampleObjectBackpack": { + "description": "Sample object note" + }, + "sampleObjectVehicle": " Terk edilmiş araç - sahibi kontrol edin", + "@sampleObjectVehicle": { + "description": "Sample object note" + }, + "sampleObjectCamping": " Kamp ekipmanı bulundu", + "@sampleObjectCamping": { + "description": "Sample object note" + }, + "sampleObjectTrailMarker": " Yol dışında patika işareti bulundu", + "@sampleObjectTrailMarker": { + "description": "Sample object note" + }, + "sampleMsgAllTeamsCheckIn": "Tüm ekipler durum bildirimi yapsın", + "@sampleMsgAllTeamsCheckIn": { + "description": "Sample channel message" + }, + "sampleMsgWeatherUpdate": "Hava durumu: açık gökyüzü, sıcaklık 18°C", + "@sampleMsgWeatherUpdate": { + "description": "Sample channel message" + }, + "sampleMsgBaseCamp": "Üs kampı toplanma alanında kuruldu", + "@sampleMsgBaseCamp": { + "description": "Sample channel message" + }, + "sampleMsgTeamAlpha": "Ekip sektör 2’ye ilerliyor", + "@sampleMsgTeamAlpha": { + "description": "Sample channel message" + }, + "sampleMsgRadioCheck": "Telsiz kontrolü - tüm istasyonlar yanıt versin", + "@sampleMsgRadioCheck": { + "description": "Sample channel message" + }, + "sampleMsgWaterSupply": "Su ikmali 3 numaralı kontrol noktasında mevcut", + "@sampleMsgWaterSupply": { + "description": "Sample channel message" + }, + "sampleMsgTeamBravo": "Ekip raporu: sektör 1 temiz", + "@sampleMsgTeamBravo": { + "description": "Sample channel message" + }, + "sampleMsgEtaRallyPoint": "Toplanma noktasına tahmini varış: 15 dakika", + "@sampleMsgEtaRallyPoint": { + "description": "Sample channel message" + }, + "sampleMsgSupplyDrop": "İkmal bırakma 14:00 için onaylandı", + "@sampleMsgSupplyDrop": { + "description": "Sample channel message" + }, + "sampleMsgDroneSurvey": "Drone taraması tamamlandı - bulgu yok", + "@sampleMsgDroneSurvey": { + "description": "Sample channel message" + }, + "sampleMsgTeamCharlie": "Ekip takviye istiyor", + "@sampleMsgTeamCharlie": { + "description": "Sample channel message" + }, + "sampleMsgRadioDiscipline": "Tüm birimler: telsiz disiplinini koruyun", + "@sampleMsgRadioDiscipline": { + "description": "Sample channel message" + }, + "sampleMsgUrgentMedical": "ACİL: sektör 4’te tıbbi yardım gerekli", + "@sampleMsgUrgentMedical": { + "description": "Sample emergency message" + }, + "sampleMsgAdultMale": " Yetişkin erkek, bilinci açık", + "@sampleMsgAdultMale": { + "description": "Sample emergency message note" + }, + "sampleMsgFireSpotted": "Yangın görüldü - koordinatlar geliyor", + "@sampleMsgFireSpotted": { + "description": "Sample emergency message" + }, + "sampleMsgSpreadingRapidly": " Hızla yayılıyor!", + "@sampleMsgSpreadingRapidly": { + "description": "Sample emergency message note" + }, + "sampleMsgPriorityHelicopter": "ÖNCELİK: helikopter desteği gerekli", + "@sampleMsgPriorityHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgMedicalTeamEnRoute": "Tıbbi ekip konumunuza geliyor", + "@sampleMsgMedicalTeamEnRoute": { + "description": "Sample emergency message" + }, + "sampleMsgEvacHelicopter": "Tahliye helikopteri ETA 10 dakika", + "@sampleMsgEvacHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyResolved": "Acil durum çözüldü - tehlike yok", + "@sampleMsgEmergencyResolved": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyStagingArea": " Acil durum toplanma alanı", + "@sampleMsgEmergencyStagingArea": { + "description": "Sample emergency message note" + }, + "sampleMsgEmergencyServices": "Acil servisler bilgilendirildi ve müdahale ediyor", + "@sampleMsgEmergencyServices": { + "description": "Sample emergency message" + }, + "sampleAlphaTeamLead": "Ekip lideri", + "@sampleAlphaTeamLead": { + "description": "Sample team name" + }, + "sampleBravoScout": "Keşifçi", + "@sampleBravoScout": { + "description": "Sample team name" + }, + "sampleCharlieMedic": "Sağlık görevlisi", + "@sampleCharlieMedic": { + "description": "Sample team name" + }, + "sampleDeltaNavigator": "Navigatör", + "@sampleDeltaNavigator": { + "description": "Sample team name" + }, + "sampleEchoSupport": "Destek", + "@sampleEchoSupport": { + "description": "Sample team name" + }, + "sampleBaseCommand": "Üs komutası", + "@sampleBaseCommand": { + "description": "Sample team name" + }, + "sampleFieldCoordinator": "Saha koordinatörü", + "@sampleFieldCoordinator": { + "description": "Sample team name" + }, + "sampleMedicalTeam": "Tıbbi ekip", + "@sampleMedicalTeam": { + "description": "Sample team name" + }, + "mapDrawing": "Harita çizimi", + "@mapDrawing": { + "description": "Label for map drawing messages" + }, + "navigateToDrawing": "Çizime git", + "@navigateToDrawing": { + "description": "Option to navigate to drawing on map" + }, + "copyCoordinates": "Koordinatları kopyala", + "@copyCoordinates": { + "description": "Option to copy coordinates to clipboard" + }, + "hideFromMap": "Haritadan gizle", + "@hideFromMap": { + "description": "Option to hide drawing from map" + }, + "lineDrawing": "Çizgi çizimi", + "@lineDrawing": { + "description": "Label for line type drawings" + }, + "rectangleDrawing": "Dikdörtgen çizimi", + "@rectangleDrawing": { + "description": "Label for rectangle type drawings" + }, + "manualCoordinates": "Manuel koordinatlar", + "@manualCoordinates": { + "description": "Label for manual coordinate input toggle" + }, + "enterCoordinatesManually": "Koordinatları manuel girin", + "@enterCoordinatesManually": { + "description": "Description for manual coordinate input option" + }, + "latitudeLabel": "Enlem", + "@latitudeLabel": { + "description": "Label for latitude input field" + }, + "longitudeLabel": "Boylam", + "@longitudeLabel": { + "description": "Label for longitude input field" + }, + "exampleCoordinates": "Örnek: 46.0569, 14.5058", + "@exampleCoordinates": { + "description": "Example coordinate format hint" + }, + "shareDrawing": "Çizimi paylaş", + "@shareDrawing": { + "description": "Title for share single drawing dialog" + }, + "shareWithAllNearbyDevices": "Yakındaki tüm cihazlarla paylaş", + "@shareWithAllNearbyDevices": { + "description": "Subtitle for public channel sharing option" + }, + "shareToRoom": "Odaya paylaş", + "@shareToRoom": { + "description": "Header for room sharing section" + }, + "sendToPersistentStorage": "Kalıcı oda depolamasına gönder", + "@sendToPersistentStorage": { + "description": "Subtitle for room sharing option" + }, + "deleteDrawingConfirm": "Bu çizimi silmek istediğinizden emin misiniz?", + "@deleteDrawingConfirm": { + "description": "Confirmation message for deleting a single drawing" + }, + "drawingDeleted": "Çizim silindi", + "@drawingDeleted": { + "description": "Success message after deleting a drawing" + }, + "yourDrawingsCount": "Çizimleriniz ({count})", + "@yourDrawingsCount": { + "description": "Header showing count of user's drawings", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shared": "Paylaşıldı", + "@shared": { + "description": "Status label for shared drawings" + }, + "line": "Çizgi", + "@line": { + "description": "Line drawing type label" + }, + "rectangle": "Dikdörtgen", + "@rectangle": { + "description": "Rectangle drawing type label" + }, + "updateAvailable": "Güncelleme mevcut", + "@updateAvailable": { + "description": "Title for update dialog when new version is available" + }, + "currentVersion": "Geçerli", + "@currentVersion": { + "description": "Label for current app version" + }, + "latestVersion": "En son", + "@latestVersion": { + "description": "Label for latest available app version" + }, + "downloadUpdate": "İndir", + "@downloadUpdate": { + "description": "Button to download app update" + }, + "updateLater": "Daha sonra", + "@updateLater": { + "description": "Button to dismiss update dialog" + }, + "cadastralParcels": "Kadastro parselleri", + "@cadastralParcels": { + "description": "Label for cadastral parcels WMS overlay layer" + }, + "forestRoads": "Orman yolları", + "@forestRoads": { + "description": "Label for forest roads WMS overlay layer" + }, + "wmsOverlays": "WMS katmanları", + "@wmsOverlays": { + "description": "Section header for WMS overlay layers in layer selector" + }, + "hikingTrails": "Yürüyüş parkurları", + "@hikingTrails": { + "description": "Label for hiking/mountain trails WMS overlay layer" + }, + "mainRoads": "Ana yollar", + "@mainRoads": { + "description": "Label for main roads WMS overlay layer" + }, + "houseNumbers": "Kapı numaraları", + "@houseNumbers": { + "description": "Label for house numbers WMS overlay layer" + }, + "fireHazardZones": "Yangın tehlike bölgeleri", + "@fireHazardZones": { + "description": "Label for fire hazard risk zones WMS overlay layer" + }, + "historicalFires": "Geçmiş yangınlar", + "@historicalFires": { + "description": "Label for historical forest fires WMS overlay layer" + }, + "firebreaks": "Yangın şeritleri", + "@firebreaks": { + "description": "Label for firebreaks WMS overlay layer" + }, + "krasFireZones": "Kras yangın bölgeleri", + "@krasFireZones": { + "description": "Label for Kras fire zones WMS overlay layer" + }, + "placeNames": "Yer adları", + "@placeNames": { + "description": "Label for geographic place names WMS overlay layer" + }, + "municipalityBorders": "Belediye sınırları", + "@municipalityBorders": { + "description": "Label for municipality borders WMS overlay layer" + }, + "topographicMap": "Topoğrafik harita 1:25000", + "@topographicMap": { + "description": "Label for DTK25 topographic base map layer" + }, + "recentMessages": "Son mesajlar", + "@recentMessages": { + "description": "Header for recent messages overlay on map in fullscreen mode" + }, + "addChannel": "Kanal ekle", + "@addChannel": { + "description": "Button to add a new channel" + }, + "channelName": "Kanal adı", + "@channelName": { + "description": "Label for channel name field" + }, + "channelNameHint": "örn. Kurtarma Ekibi Alfa", + "@channelNameHint": { + "description": "Hint for channel name field" + }, + "channelSecret": "Kanal sırrı", + "@channelSecret": { + "description": "Label for channel secret field" + }, + "channelSecretHint": "Bu kanal için paylaşılan parola", + "@channelSecretHint": { + "description": "Hint for channel secret field" + }, + "channelSecretHelp": "Bu sır, bu kanala erişmesi gereken tüm ekip üyeleriyle paylaşılmalıdır", + "@channelSecretHelp": { + "description": "Help text explaining channel secret" + }, + "channelTypesInfo": "Hash kanallar (#team): sır, isimden otomatik üretilir. Aynı isim = tüm cihazlarda aynı kanal.\n\nÖzel kanallar: açık bir sır kullanın. Yalnızca sırrı bilenler katılabilir.", + "@channelTypesInfo": { + "description": "Information banner explaining hash and private channel types" + }, + "hashChannelInfo": "Hash kanal: sır kanal adından otomatik üretilecektir. Aynı adı kullanan herkes aynı kanala katılır.", + "@hashChannelInfo": { + "description": "Help text for hash channels (# prefix)" + }, + "channelNameRequired": "Kanal adı gerekli", + "@channelNameRequired": { + "description": "Validation error for empty channel name" + }, + "channelNameTooLong": "Kanal adı en fazla 31 karakter olabilir", + "@channelNameTooLong": { + "description": "Validation error for channel name too long" + }, + "channelSecretRequired": "Kanal sırrı gerekli", + "@channelSecretRequired": { + "description": "Validation error for empty channel secret" + }, + "channelSecretTooLong": "Kanal sırrı en fazla 32 karakter olabilir", + "@channelSecretTooLong": { + "description": "Validation error for channel secret too long" + }, + "invalidAsciiCharacters": "Yalnızca ASCII karakterlere izin verilir", + "@invalidAsciiCharacters": { + "description": "Validation error for non-ASCII characters" + }, + "channelCreatedSuccessfully": "Kanal başarıyla oluşturuldu", + "@channelCreatedSuccessfully": { + "description": "Success message after creating channel" + }, + "channelCreationFailed": "Kanal oluşturulamadı: {error}", + "@channelCreationFailed": { + "description": "Error message when channel creation fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deleteChannel": "Kanalı sil", + "@deleteChannel": { + "description": "Delete channel button/menu item" + }, + "deleteChannelConfirmation": "\"{channelName}\" kanalını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "@deleteChannelConfirmation": { + "description": "Confirmation dialog when deleting a channel", + "placeholders": { + "channelName": { + "type": "String" + } + } + }, + "channelDeletedSuccessfully": "Kanal başarıyla silindi", + "@channelDeletedSuccessfully": { + "description": "Success message after deleting channel" + }, + "channelDeletionFailed": "Kanal silinemedi: {error}", + "@channelDeletionFailed": { + "description": "Error message when channel deletion fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "createChannel": "Kanal oluştur", + "@createChannel": { + "description": "Button text for creating a channel" + }, + "wizardBack": "Geri", + "@wizardBack": { + "description": "Wizard back button text" + }, + "wizardSkip": "Atla", + "@wizardSkip": { + "description": "Wizard skip button text" + }, + "wizardNext": "İleri", + "@wizardNext": { + "description": "Wizard next button text" + }, + "wizardGetStarted": "Başla", + "@wizardGetStarted": { + "description": "Wizard final button text to complete onboarding" + }, + "wizardWelcomeTitle": "MeshCore SAR uygulamasına hoş geldiniz", + "@wizardWelcomeTitle": { + "description": "Welcome wizard first page title" + }, + "viewWelcomeTutorial": "Karşılama eğitimini görüntüle", + "@viewWelcomeTutorial": { + "description": "Settings option to re-show welcome wizard" + }, + "allTeamContacts": "Tüm ekip kişileri", + "@allTeamContacts": { + "description": "Destination option to send SAR marker to all team contacts" + }, + "directMessagesInfo": "ACK’li doğrudan mesajlar. {count} ekip üyesine gönderildi.", + "@directMessagesInfo": { + "description": "Information about sending to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "sarMarkerSentToContacts": "SAR işareti {count} kişiye gönderildi", + "@sarMarkerSentToContacts": { + "description": "Success message after sending SAR marker to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noContactsAvailable": "Ekip kişisi yok", + "@noContactsAvailable": { + "description": "Message when there are no chat contacts to send to" + }, + "reply": "Yanıtla", + "@reply": {}, + "technicalDetails": "Teknik ayrıntılar", + "@technicalDetails": {}, + "messageTechnicalDetails": "Mesaj teknik ayrıntıları", + "@messageTechnicalDetails": {}, + "linkQuality": "Bağlantı kalitesi", + "@linkQuality": {}, + "delivery": "Teslimat", + "@delivery": {}, + "status": "Durum", + "@status": {}, + "expectedAckTag": "Beklenen ACK etiketi", + "@expectedAckTag": {}, + "roundTrip": "Gidiş dönüş", + "@roundTrip": {}, + "retryAttempt": "Yeniden deneme sayısı", + "@retryAttempt": {}, + "floodFallback": "Flood yedeği", + "@floodFallback": {}, + "identity": "Kimlik", + "@identity": {}, + "messageId": "Mesaj kimliği", + "@messageId": {}, + "sender": "Gönderen", + "@sender": {}, + "senderKey": "Gönderen anahtarı", + "@senderKey": {}, + "recipient": "Alıcı", + "@recipient": {}, + "recipientKey": "Alıcı anahtarı", + "@recipientKey": {}, + "voice": "Ses", + "@voice": {}, + "voiceId": "Ses kimliği", + "@voiceId": {}, + "envelope": "Zarf", + "@envelope": {}, + "sessionProgress": "Oturum ilerlemesi", + "@sessionProgress": {}, + "complete": "Tamamlandı", + "@complete": {}, + "rawDump": "Ham döküm", + "@rawDump": {}, + "cannotRetryMissingRecipient": "Yeniden denenemiyor: alıcı bilgisi eksik", + "@cannotRetryMissingRecipient": {}, + "voiceUnavailable": "Ses şu anda kullanılamıyor", + "@voiceUnavailable": {}, + "requestingVoice": "Ses isteniyor", + "@requestingVoice": {}, + "device": "cihaz", + "change": "Değiştir", + "wizardOverviewDescription": "Bu uygulama MeshCore mesajlaşmasını, SAR saha güncellemelerini, haritalamayı ve cihaz araçlarını tek bir yerde birleştirir.", + "wizardOverviewFeature1": "Ana Mesajlar sekmesinden doğrudan mesajlar, oda gönderileri ve kanal mesajları gönderin.", + "wizardOverviewFeature2": "Mesh üzerinden SAR işaretleri, harita çizimleri, ses klipleri ve görseller paylaşın.", + "wizardOverviewFeature3": "BLE veya TCP ile bağlanın, ardından yardımcı radyoyu uygulamanın içinden yönetin.", + "wizardMessagingTitle": "Mesajlaşma ve saha raporları", + "wizardMessagingDescription": "Buradaki mesajlar düz metinden fazlasıdır. Uygulama zaten çeşitli operasyonel yükleri ve aktarım akışlarını destekliyor.", + "wizardMessagingFeature1": "Doğrudan mesajları, oda gönderilerini ve kanal trafiğini tek bir düzenleyiciden gönderin.", + "wizardMessagingFeature2": "Yaygın saha raporları için SAR güncellemeleri ve yeniden kullanılabilir SAR şablonları oluşturun.", + "wizardMessagingFeature3": "Arayüzde ilerleme ve yayın süresi tahminleriyle ses oturumları ve görseller aktarın.", + "wizardConnectDeviceTitle": "Cihazı bağla", + "wizardConnectDeviceDescription": "MeshCore radyonuzu bağlayın, bir ad seçin ve devam etmeden önce bir radyo ön ayarı uygulayın.", + "wizardSetupBadge": "Kurulum", + "wizardOverviewBadge": "Genel bakış", + "wizardConnectedToDevice": "{deviceName} cihazına bağlandı", + "wizardNoDeviceConnected": "Henüz bağlı cihaz yok", + "wizardSkipForNow": "Şimdilik atla", + "wizardDeviceNameLabel": "Cihaz adı", + "wizardDeviceNameHelp": "Bu ad diğer MeshCore kullanıcılarına duyurulur.", + "wizardConfigRegionLabel": "Yapılandırma bölgesi", + "wizardConfigRegionHelp": "MeshCore'un resmi ön ayar listesinin tamamını kullanır. Varsayılan EU/UK (Narrow) olur.", + "wizardPresetNote1": "Seçilen ön ayarın yerel radyo düzenlemelerinize uygun olduğundan emin olun.", + "wizardPresetNote2": "Liste, MeshCore yapılandırma aracının resmi ön ayar akışıyla eşleşir.", + "wizardPresetNote3": "Onboarding sırasında varsayılan olarak EU/UK (Narrow) seçili kalır.", + "wizardSaving": "Kaydediliyor...", + "wizardSaveAndContinue": "Kaydet ve devam et", + "wizardEnterDeviceName": "Devam etmeden önce bir cihaz adı girin.", + "wizardDeviceSetupSaved": "{deviceName}, {presetName} ile kaydedildi.", + "wizardNetworkTitle": "Kişiler, odalar ve tekrarlayıcılar", + "wizardNetworkDescription": "Kişiler sekmesi keşfettiğiniz ağı ve zamanla öğrendiğiniz rotaları düzenler.", + "wizardNetworkFeature1": "Ekip üyelerini, tekrarlayıcıları, odaları, kanalları ve bekleyen duyuruları tek listede inceleyin.", + "wizardNetworkFeature2": "Bağlantı karmaşıklaştığında smart ping, oda girişi, öğrenilen yollar ve rota sıfırlama araçlarını kullanın.", + "wizardNetworkFeature3": "Uygulamadan çıkmadan kanallar oluşturun ve ağ hedeflerini yönetin.", + "wizardMapOpsTitle": "Harita, izler ve paylaşılan geometri", + "wizardMapOpsDescription": "Uygulama haritası ayrı bir görüntüleyici olmak yerine mesajlaşma, takip ve SAR katmanlarıyla doğrudan bağlantılıdır.", + "wizardMapOpsFeature1": "Kendi konumunuzu, ekip arkadaşlarının konumlarını ve hareket izlerini harita üzerinde takip edin.", + "wizardMapOpsFeature2": "Mesajlardaki çizimleri açın, satır içinde önizleyin ve gerektiğinde haritadan kaldırın.", + "wizardMapOpsFeature3": "Sahadaki ağ kapsamasını anlamak için tekrarlayıcı harita görünümleri ve paylaşılan katmanları kullanın.", + "wizardToolsTitle": "Mesajlaşma dışındaki araçlar", + "wizardToolsDescription": "Burada dört ana sekmeden fazlası var. Uygulama ayrıca yapılandırma, tanılama ve isteğe bağlı sensör akışları içerir.", + "wizardToolsFeature1": "Radyo ayarlarını, telemetriyi, TX gücünü ve yardımcı cihaz ayrıntılarını değiştirmek için cihaz yapılandırmasını açın.", + "wizardToolsFeature2": "İzlenen sensör panoları ve hızlı yenileme eylemleri istediğinizde Sensörler sekmesini etkinleştirin.", + "wizardToolsFeature3": "Mesh sorunlarını giderirken paket günlükleri, spektrum taraması ve geliştirici tanılamalarını kullanın.", + "contactInSensors": "Sensörlerde", + "contactAddToSensors": "Sensörlere ekle", + "contactSetPath": "Yol ayarla", + "contactAddedToSensors": "{contactName} Sensörlere eklendi", + "contactFailedToClearRoute": "Rota temizlenemedi: {error}", + "contactRouteCleared": "Rota temizlendi", + "contactRouteSet": "Rota ayarlandı: {route}", + "contactFailedToSetRoute": "Rota ayarlanamadı: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "ACK zaman aşımı", + "opcode": "Opcode", + "payload": "Yük", + "hops": "Atlama", + "hashSize": "Hash boyutu", + "pathBytes": "Yol baytları", + "selectedPath": "Seçilen yol", + "estimatedTx": "Tahmini gönderim", + "senderToReceipt": "Gönderenden alındıya", + "receivedCopies": "Alınan kopyalar", + "retryCause": "Yeniden deneme nedeni", + "retryMode": "Yeniden deneme modu", + "retryResult": "Yeniden deneme sonucu", + "lastRetry": "Son deneme", + "rxPackets": "RX paketleri", + "mesh": "Mesh", + "rate": "Hız", + "window": "Pencere", + "posttxDelay": "Gönderim sonrası gecikme", + "bandpass": "Bant geçiren", + "bandpassFilterVoice": "Bant geçiren filtre ses", + "active": "Aktif", + "addContact": "Kişi ekle", + "all": "Tümü", + "clearAllLabel": "Tümünü temizle", + "clearFilters": "Filtreleri temizle", + "clearRoute": "Rotayı temizle", + "clearMessages": "Mesajları temizle", + "clearScale": "Ölçeği temizle", + "clearDiscoveries": "Keşifleri temizle", + "clearOnlineTraceDatabase": "Çevrimiçi iz veritabanını temizle", + "clearAllChannels": "Tüm kanalları temizle", + "clearAllContacts": "Tüm kişileri temizle", + "clearChannels": "Kanalları temizle", + "clearContacts": "Kişileri temizle", + "clearPathOnMaxRetry": "Maks denemede yolu temizle", + "create": "Oluştur", + "custom": "Özel", + "defaultValue": "Varsayılan", + "duplicate": "Çoğalt", + "editName": "Adı düzenle", + "open": "Aç", + "paste": "Yapıştır", + "preview": "Önizleme", + "remove": "Kaldır", + "rename": "Yeniden adlandır", + "resolveAll": "Tümünü çöz", + "send": "Gönder", + "sendAnyway": "Yine de gönder", + "share": "Paylaş", + "trace": "İz", + "discovery": "Keşif", + "discoverRepeaters": "Tekrarlayıcıları keşfet", + "discoverSensors": "Sensörleri keşfet", + "repeaterDiscoverySent": "Tekrarlayıcı keşfi gönderildi", + "sensorDiscoverySent": "Sensör keşfi gönderildi", + "clearedPendingDiscoveries": "Bekleyen keşifler temizlendi.", + "autoDiscovery": "Otomatik keşif", + "enableAutomaticAdding": "Otomatik eklemeyi etkinleştir", + "autoaddRepeaters": "Tekrarlayıcıları otomatik ekle", + "autoaddRoomServers": "Oda sunucularını otomatik ekle", + "autoaddSensors": "Sensörleri otomatik ekle", + "autoaddUsers": "Kullanıcıları otomatik ekle", + "overwriteOldestWhenFull": "Dolduğunda en eskiyi üzerine yaz", + "storage": "Depolama", + "dangerZone": "Tehlikeli bölge", + "profiles": "Profiller", + "favourites": "Favoriler", + "sensors": "Sensörler", + "others": "Diğerleri", + "gpsModule": "GPS Modülü", + "liveTraffic": "Canlı trafik", + "repeatersMap": "Tekrarlayıcılar haritası", + "blePacketLogs": "BLE paket günlükleri", + "onlineTraceDatabase": "Çevrimiçi iz veritabanı", + "routePathByteSize": "Yol bayt boyutu", + "messageNotifications": "Mesaj bildirimleri", + "sarAlerts": "SAR uyarıları", + "discoveryNotifications": "Keşif bildirimleri", + "updateNotifications": "Güncelleme bildirimleri", + "muteWhileAppIsOpen": "Uygulama açıkken sessize al", + "disableContacts": "Kişileri devre dışı bırak", + "enableSensorsTab": "Sensörler sekmesini etkinleştir", + "enableProfiles": "Profilleri etkinleştir", + "nearestRepeaterFallback": "En yakın tekrarlayıcıya geri dönüş", + "deleteAllStoredMessageHistory": "Tüm mesaj geçmişini sil", + "messageFontSize": "Mesaj yazı tipi boyutu", + "rotateMapWithHeading": "Haritayı yön ile döndür", + "showMapDebugInfo": "Harita hata ayıklama bilgisini göster", + "openMapInFullscreen": "Haritayı tam ekranda aç", + "showSarMarkersLabel": "SAR işaretçilerini göster", + "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ı", + "voiceLimiter": "Ses sınırlayıcı", + "micAutoGain": "Mikrofon otomatik kazancı", + "echoCancellation": "Yankı iptali", + "noiseSuppression": "Gürültü bastırma", + "trimSilenceInVoiceMessages": "Sesli mesajlarda sessizliği kırp", + "compressor": "Sıkıştırıcı", + "limiter": "Sınırlayıcı", + "autoGain": "Otomatik kazanç", + "echoCancel": "Yankı", + "noiseSuppress": "Gürültü", + "silenceTrim": "Sessizlik", + "maxImageSize": "Maksimum resim boyutu", + "imageCompression": "Resim sıkıştırma", + "grayscale": "Gri tonlama", + "ultraMode": "Ultra mod", + "fastPrivateGpsUpdates": "Hızlı özel GPS güncellemeleri", + "movementThreshold": "Hareket eşiği", + "fastGpsMovementThreshold": "Hızlı GPS hareket eşiği", + "fastGpsActiveuseInterval": "Hızlı GPS aktif kullanım aralığı", + "activeuseUpdateInterval": "Aktif kullanım güncelleme aralığı", + "repeatNearbyTraffic": "Yakın trafiği tekrarla", + "relayThroughRepeatersAcrossTheMesh": "Ağ üzerindeki tekrarlayıcılar aracılığıyla ilet", + "nearbyOnlyWithoutRepeaterFlooding": "Yalnızca yakın, tekrarlayıcı taşkını olmadan", + "multihop": "Çok atlamalı", + "createProfile": "Profil oluştur", + "renameProfile": "Profili yeniden adlandır", + "newProfile": "Yeni profil", + "manageProfiles": "Profilleri yönet", + "enableProfilesToStartManagingThem": "Profilleri yönetmeye başlamak için etkinleştirin.", + "openMessage": "Mesajı aç", + "jumpToTheRelatedSarMessage": "İlgili SAR mesajına git", + "removeSarMarker": "SAR işaretçisini kaldır", + "pleaseSelectADestinationToSendSarMarker": "SAR işaretçisi göndermek için bir hedef seçin", + "sarMarkerBroadcastToPublicChannel": "SAR işaretçisi genel kanala yayınlandı", + "sarMarkerSentToRoom": "SAR işaretçisi odaya gönderildi", + "loadFromGallery": "Galeriden yükle", + "replaceImage": "Resmi değiştir", + "selectFromGallery": "Galeriden seç", + "team": "Takım", + "found": "Bulundu", + "staging": "Toplanma alanı", + "object": "Nesne", + "quiet": "Sessiz", + "busy": "Meşgul", + "searchMessages": "Mesajlarda ara", + "sendImageFromGallery": "Galeriden resim gönder", + "takePhoto": "Fotoğraf çek", + "allMessages": "Tüm mesajlar", + "sendToPublicChannel": "Genel kanala gönderilsin mi?", + "selectMarkerTypeAndDestination": "İşaretçi türünü ve hedefi seçin", + "noDestinationsAvailableLabel": "Kullanılabilir hedef yok", + "image": "Resim", + "format": "Biçim", + "dimensions": "Boyutlar", + "segments": "Segmentler", + "transfers": "Transferler", + "downloadedBy": "İndiren", + "saveDiscoverySettings": "Keşif ayarlarını kaydet", + "savePublicInfo": "Genel bilgileri kaydet", + "saveRadioSettings": "Radyo ayarlarını kaydet", + "savePath": "Yolu kaydet", + "wipeDeviceData": "Cihaz verilerini sil", + "wipeDevice": "Cihazı sil", + "destructiveDeviceActions": "Yıkıcı cihaz işlemleri.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Bir ön ayar seçin veya radyo ayarlarını ince ayarlayın.", + "used": "Kullanılan", + "total": "Toplam", + "renameValue": "Değeri yeniden adlandır", + "customizeFields": "Alanları özelleştir", + "livePreview": "Canlı önizleme", + "refreshSchedule": "Yenileme programı", + "noResponse": "Yanıt yok", + "refreshing": "Yenileniyor", + "unavailable": "Kullanılamaz", + "pickARelayOrNodeToWatchInSensors": "İzlemek için bir röle veya düğüm seçin.", + "publicKeyLabel": "Genel anahtar", + "alreadyInContacts": "Zaten kişilerde", + "connectToADeviceBeforeAddingContacts": "Kişi eklemeden önce bir cihaza bağlanın", + "fromContacts": "Kişilerden", + "onlineOnly": "Yalnızca çevrimiçi", + "inBoth": "Her ikisinde", + "source": "Kaynak", + "allMessagesCleared": "Tüm mesajlar temizlendi", + "onlineTraceDatabaseCleared": "Çevrimiçi iz veritabanı temizlendi", + "packetLogsCleared": "Paket günlükleri temizlendi", + "hexDataCopiedToClipboard": "Hex verisi panoya kopyalandı", + "developerModeEnabled": "Geliştirici modu etkinleştirildi", + "developerModeDisabled": "Geliştirici modu devre dışı bırakıldı", + "clipboardIsEmpty": "Pano boş", + "contactImported": "Kişi içe aktarıldı", + "contactLinkCopiedToClipboard": "Kişi bağlantısı panoya kopyalandı", + "failedToExportContact": "Kişi dışa aktarılamadı", + "noLogsToExport": "Dışa aktarılacak günlük yok", + "exportAsCsv": "CSV olarak dışa aktar", + "exportAsText": "Metin olarak dışa aktar", + "receivedRfc3339": "Alındı (RFC3339)", + "buildTime": "Derleme zamanı", + "downloadUrlNotAvailable": "İndirme URL'si mevcut değil", + "cannotOpenDownloadUrl": "İndirme URL'si açılamıyor", + "updateCheckIsOnlyAvailableOnAndroid": "Güncelleme kontrolü yalnızca Android'de kullanılabilir", + "youAreRunningTheLatestVersion": "En son sürümü kullanıyorsunuz", + "updateAvailableButDownloadUrlNotFound": "Güncelleme mevcut ancak indirme URL'si bulunamadı", + "startTictactoe": "Tic-Tac-Toe başlat", + "tictactoeUnavailable": "Tic-Tac-Toe kullanılamıyor", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: rakip bilinmiyor", + "tictactoeWaitingForStart": "Tic-Tac-Toe: başlangıç bekleniyor", + "acceptsShareLinks": "Paylaşım bağlantılarını kabul eder", + "supportsRawHex": "Ham hex destekler", + "clipboardfriendly": "Pano dostu", + "captured": "Yakalandı", + "size": "Boyut", + "noCustomChannelsToClear": "Temizlenecek özel kanal yok.", + "noDeviceContactsToClear": "Temizlenecek cihaz kişisi yok.", + "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", + "simpleMode": "Basit mod", + "simpleModeDescription": "Yalnızca favori kişilerle sohbet ve harita", + "simpleModeRequiresLockedDestination": "Basit modu kullanmak için önce bir kanalla \"Hedefi kilitle\" seçeneğini etkinleştirin", + "noFavouriteContacts": "Favori kişi yok", + "selectDestinationFirst": "Önce bir kanal, kişi veya oda seçin", + "recipientNoLongerAvailable": "Alıcı artık mevcut değil", + "failedToSendMessage": "Gönderilemedi: {error}", + "tictactoeDirectOnly": "XOX yalnızca doğrudan mesajlarda çalışır. Önce bir kişi seçin.", + "deviceKeyUnavailable": "Cihaz anahtarı mevcut değil", + "imageCompressionFailed": "Resim sıkıştırma başarısız", + "imageFragmentationFailed": "Resim parçalama başarısız", + "failedToAnnounceImage": "Resim duyurulamadı", + "noRecipientSelected": "Alıcı seçilmedi", + "imageSendFailed": "Resim gönderilemedi", + "microphonePermissionRequiredForVoice": "Sesli mesajlar için mikrofon izni gerekli", + "recordingDiscarded": "Kayıt silindi", + "publicChannelMediaWarning": "Genel kanala {mediaType} göndermek üzeresiniz. Mesh ağındaki herkes bunu alabileceği için bu önerilmez. Bunu özellikle istemiyorsanız özel veya etiketli bir kanal seçin.", + "selectPrivateChannelToShareLocation": "Konumunuzu paylaşmak için özel bir kanal seçin.", + "stopSharingMyLocation": "Konumumu paylaşmayı durdur", + "shareMyLocation": "Konumumu paylaş", + "stopRecording": "Kaydı durdur", + "recordVoice": "Sesli mesaj kaydet", + "moreActions": "Diğer işlemler", + "noMessagesInFilter": "Bu filtrede mesaj yok", + "noMatchesInFilter": "Bu filtrede eşleşme yok", + "syncFailed": "Senkronizasyon başarısız: {error}", + "invalidLink": "Geçersiz bağlantı", + "cannotOpenLink": "Bağlantı açılamıyor", + "failedToOpenLink": "Bağlantı açılamadı", + "failedToResendMessage": "Mesaj yeniden gönderilemedi", + "retryFailed": "Yeniden deneme başarısız: {error}", + "deleteDrawingAndMessage": "Çizimi ve mesajı sil", + "lastEchoRelay": "Son yankı: röle", + "lastEchoPath": "Son yankı: yol", + "lastEchoBytesReport": "Son yankı: bayt raporu", + "noGpsCoordinatesAvailable": "Kullanılabilir GPS koordinatı yok", + "notASarMarker": "Bu bir SAR işareti değil", + "failedToSaveTemplate": "Şablon kaydedilemedi: {error}", + "drawingNotFound": "Çizim bulunamadı", + "unknownDrawingType": "Bilinmeyen çizim türü", + "customMapMarker": "Özel harita işareti", + "customMapPointLabel": "Nokta: {point}", + "mapIdLabel": "Harita ID: {id}", + "cannotFetchVoice": "Ses alınamıyor", + "senderContactUnknown": "Gönderenin kişisi bilinmiyor. Önce kişileri senkronize edin.", + "senderRouteUnknown": "Gönderenin rotası bilinmiyor. Önce kişileri/yolu senkronize edin.", + "messageTooFar": "Mesaj çok uzakta ({hops} atlama, en fazla {maxHops}).", + "senderRouteNoPathResponse": "Gönderenin rotası yol denetimine yanıt vermedi. Kişileri/yolu senkronize edip yeniden deneyin.", + "senderRouteNoRawResponse": "Gönderenin rotası ham aktarım yolunda yanıt vermedi.", + "voiceFetchOverHops": "{hops} atlama üzerinden ses alma biraz sürebilir.", + "voiceReceiveCanceled": "Ses alımı iptal edildi", + "fetchingMissingVoiceFragments": "Eksik ses parçaları alınıyor", + "receivingVoice": "Ses alınıyor", + "cannotFetchImage": "Resim alınamıyor", + "imageFetchOverHops": "{hops} atlama üzerinden resim alma biraz sürebilir.", + "imageAlreadyBeingReceived": "Resim zaten alınıyor", + "loadImage": "Resmi yükle", + "imageFetchFailedToSendRequest": "Resim isteği gönderilemedi", + "imageUnavailable": "Resim şu anda kullanılamıyor", + "imageFetchTimedOut": "Resim alma zaman aşımına uğradı", + "imageReceiveCanceled": "Resim alımı iptal edildi", + "closeImagePreview": "Resim önizlemesini kapat", + "recordingReleaseToSend": "Kaydediliyor... göndermek için bırakın", + "sendingVoice": "Ses gönderiliyor...", + "sendLongPressToRecordVoice": "Gönder (ses kaydı için basılı tutun)", + "discardRecording": "Kaydı sil", + "hopCount": "{count, plural, other{{count} atlama}}", + "channelsSectionDescription": "Yakındaki mesh trafiği için yayın kanalları", + "roomsSectionDescription": "Sürekli ekip koordinasyonu için ortak alanlar", + "contactsSectionDescription": "Doğrudan ulaşabileceğiniz kişiler ve cihazlar", + "noRecentChatters": "Yakın zamanda konuşan yok", + "public": "Genel", + "failedToSendTicTacToeMove": "XOX hamlesi gönderilemedi", + "youWon": "Kazandınız", + "opponentWon": "Rakip kazandı", + "gameDraw": "Berabere", + "yourTurn": "Sıra sizde", + "opponentTurn": "Sıra rakipte", + "pleaseEnterValidCoordinates": "Geçerli koordinatlar girin", + "locationNotAvailable": "Konum mevcut değil", + "customCaveMapPoint": "Özel mağara haritası noktası", + "mapPoint": "Harita noktası", + "fetchingMissingImageFragments": "Eksik parçalar alınıyor…", + "loadingImage": "Yükleniyor…", + "receivingImage": "Alınıyor…", + "tapToLoad": "Yüklemek için dokunun", + "connectDevice": "Cihaza bağlan", + "chooseTransportSubtitle": "Bluetooth, WiFi veya Seri aktarım seçin", + "ble": "BLE", + "network": "Ağ", + "serial": "Seri", + "scan": "Tara", + "pressScanToSearchForDevices": "Yakındaki cihazları aramak için tara düğmesine basın", + "failedToConnectToDevice": "{name} cihazına bağlanılamadı", + "unknownDevice": "Bilinmeyen cihaz", + "showingCachedResultsTapRefresh": "Önbelleğe alınmış sonuçlar gösteriliyor. Yeniden taramak için yenile düğmesine dokunun.", + "scanningLocalNetworkOnPort": "Yerel ağda {port} bağlantı noktasındaki MeshCore WiFi cihazları taranıyor", + "cancelScanAndAddServer": "Taramayı iptal et ve sunucu ekle", + "addServer": "Sunucu ekle", + "scanningProgressIps": "Taranıyor... {scanned}/{total} IP", + "cancelAndEnterManually": "İptal et ve elle gir", + "noRecentOrDiscoveredServers": "Henüz son kullanılan veya keşfedilen sunucu yok", + "recentlyUsed": "Son kullanılanlar", + "connecting": "Bağlanıyor...", + "discoveredOnThisNetwork": "Bu ağda keşfedildi", + "serverNoLongerAvailable": "{host}:{port} adresindeki sunucu artık kullanılamıyor. Etkin sunucuları bulmak için lütfen yeniden tarayın.", + "failedToConnectToHost": "{host}:{port} adresine bağlanılamadı", + "enterValidIpAddress": "Geçerli bir IP adresi girin", + "enterValidTcpPort": "Geçerli bir TCP bağlantı noktası girin", + "ipAddress": "IP adresi", + "tcpPort": "TCP bağlantı noktası", + "customServerPort": "Özel sunucu bağlantı noktası", + "chat": "Sohbet", + "restoringPreviousLink": "Önceki bağlantı geri yükleniyor", + "noDeviceConnected": "Bağlı cihaz yok", + "sendAdvert": "Kişi bilgimi gönder", + "advertMode": "Kişi bilgimi gönder", + "advertModeSubtitle": "Kişi bilgilerinizin ne kadar uzağa gideceğini seçin.", + "floodAdvertSent": "Kişi bilgisi tüm mesh ağına gönderildi", + "directAdvertSent": "Kişi bilgisi yakındaki cihazlara gönderildi" +} diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb new file mode 100644 index 0000000..edffbb1 --- /dev/null +++ b/lib/l10n/app_uk.arb @@ -0,0 +1,2617 @@ +{ + "@@locale": "uk", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "The application title" + }, + "messages": "Повідомлення", + "@messages": { + "description": "Messages tab label" + }, + "contacts": "Контакти", + "@contacts": { + "description": "Contacts tab label" + }, + "map": "Мапа", + "@map": { + "description": "Map tab label" + }, + "settings": "Налаштування", + "@settings": { + "description": "Settings screen title" + }, + "connect": "Підключити", + "@connect": { + "description": "Connect button label" + }, + "disconnect": "Відключити", + "@disconnect": { + "description": "Disconnect button label" + }, + "noDevicesFound": "Пристроїв не знайдено", + "@noDevicesFound": { + "description": "Text shown when no BLE devices are found" + }, + "scanAgain": "Сканувати знову", + "@scanAgain": { + "description": "Button to restart BLE scanning" + }, + "deviceNotConnected": "Пристрій не підключено", + "@deviceNotConnected": { + "description": "Error message when device is not connected" + }, + "locationPermissionDenied": "Доступ до геолокації відхилено", + "@locationPermissionDenied": { + "description": "Error when location permission is denied" + }, + "locationPermissionPermanentlyDenied": "Дозвіл на геолокацію назавжди відхилено. Увімкніть його в Налаштуваннях.", + "@locationPermissionPermanentlyDenied": { + "description": "Error when location permission is permanently denied" + }, + "locationPermissionRequired": "Для GPS-відстеження та координації команди потрібен дозвіл на геолокацію. Ви можете ввімкнути його пізніше в Налаштуваннях.", + "@locationPermissionRequired": { + "description": "Message when location permission is needed" + }, + "locationServicesDisabled": "Служби геолокації вимкнені. Увімкніть їх у Налаштуваннях.", + "@locationServicesDisabled": { + "description": "Error when location services are disabled" + }, + "failedToGetGpsLocation": "Не вдалося отримати GPS-координати", + "@failedToGetGpsLocation": { + "description": "Error when GPS location cannot be obtained" + }, + "failedToAdvertise": "Не вдалося транслювати: {error}", + "@failedToAdvertise": { + "description": "Error message for failed advertisement", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "Скасувати повторне підключення", + "@cancelReconnection": { + "description": "Tooltip for cancel reconnection button" + }, + "general": "Загальні", + "@general": { + "description": "General settings section header" + }, + "theme": "Тема", + "@theme": { + "description": "Theme setting label" + }, + "chooseTheme": "Вибрати тему", + "@chooseTheme": { + "description": "Theme selection dialog title" + }, + "light": "Світла", + "@light": { + "description": "Light theme option" + }, + "dark": "Темна", + "@dark": { + "description": "Dark theme option" + }, + "blueLightTheme": "Світло-синя тема", + "@blueLightTheme": { + "description": "Description for blue light theme" + }, + "blueDarkTheme": "Темно-синя тема", + "@blueDarkTheme": { + "description": "Description for blue dark theme" + }, + "sarRed": "SAR Червоний", + "@sarRed": { + "description": "SAR Red theme option" + }, + "alertEmergencyMode": "Режим тривоги/надзвичайної ситуації", + "@alertEmergencyMode": { + "description": "Description for SAR Red theme" + }, + "sarGreen": "SAR Зелений", + "@sarGreen": { + "description": "SAR Green theme option" + }, + "safeAllClearMode": "Безпечний режим/усе чисто", + "@safeAllClearMode": { + "description": "Description for SAR Green theme" + }, + "autoSystem": "Авто (система)", + "@autoSystem": { + "description": "Auto/System theme option" + }, + "followSystemTheme": "Слідувати системній темі", + "@followSystemTheme": { + "description": "Description for system theme" + }, + "showRxTxIndicators": "Показувати індикатори RX/TX", + "@showRxTxIndicators": { + "description": "Setting to show RX/TX indicators" + }, + "disableMap": "Вимкнути мапу", + "@disableMap": { + "description": "Setting to disable the map tab" + }, + "language": "Мова", + "@language": { + "description": "Language setting label" + }, + "chooseLanguage": "Вибрати мову", + "@chooseLanguage": { + "description": "Language selection dialog title" + }, + "save": "Зберегти", + "@save": { + "description": "Save button label" + }, + "cancel": "Скасувати", + "@cancel": { + "description": "Cancel button label" + }, + "close": "Закрити", + "@close": { + "description": "Close button label" + }, + "about": "Про програму", + "@about": { + "description": "About section header" + }, + "appVersion": "Версія застосунку", + "@appVersion": { + "description": "App version label" + }, + "aboutMeshCoreSar": "Про MeshCore SAR", + "@aboutMeshCoreSar": { + "description": "About dialog title" + }, + "aboutDescription": "Застосунок для пошуку та рятування, створений для команд екстреного реагування. Можливості:\n\n• BLE mesh-мережа для зв’язку між пристроями\n• Офлайн-мапи з кількома шарами\n• Відстеження членів команди в реальному часі\n• Тактичні маркери SAR (знайдена людина, пожежа, зона збору)\n• Керування контактами та повідомленнями\n• GPS-відстеження з компасом\n• Кешування тайлів мапи для офлайн-використання", + "@aboutDescription": { + "description": "About dialog description" + }, + "technologiesUsed": "Використані технології:", + "@technologiesUsed": { + "description": "Technologies used section title" + }, + "technologiesList": "• Flutter для кросплатформної розробки\n• BLE (Bluetooth Low Energy) для mesh-мережі\n• OpenStreetMap для мап\n• Provider для керування станом\n• SharedPreferences для локального зберігання", + "@technologiesList": { + "description": "List of technologies used" + }, + "moreInfo": "Більше інформації", + "@moreInfo": { + "description": "More info button label" + }, + "packageName": "Назва пакета", + "@packageName": { + "description": "Package name label" + }, + "sampleData": "Тестові дані", + "@sampleData": { + "description": "Sample data section header" + }, + "sampleDataDescription": "Завантажити або очистити тестові контакти, повідомлення каналів і маркери SAR для перевірки", + "@sampleDataDescription": { + "description": "Sample data section description" + }, + "loadSampleData": "Завантажити тестові дані", + "@loadSampleData": { + "description": "Load sample data button" + }, + "clearAllData": "Очистити всі дані", + "@clearAllData": { + "description": "Clear all data button" + }, + "clearAllDataConfirmTitle": "Очистити всі дані", + "@clearAllDataConfirmTitle": { + "description": "Clear data confirmation dialog title" + }, + "clearAllDataConfirmMessage": "Це очистить усі контакти та маркери SAR. Ви впевнені?", + "@clearAllDataConfirmMessage": { + "description": "Clear data confirmation message" + }, + "clear": "Очистити", + "@clear": { + "description": "Clear button label" + }, + "loadedSampleData": "Завантажено {teamCount} членів команди, {channelCount} каналів, {sarCount} маркерів SAR, {messageCount} повідомлень", + "@loadedSampleData": { + "description": "Success message after loading sample data", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "Не вдалося завантажити тестові дані: {error}", + "@failedToLoadSampleData": { + "description": "Error message when sample data fails to load", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "Усі дані очищено", + "@allDataCleared": { + "description": "Success message after clearing all data" + }, + "failedToStartBackgroundTracking": "Не вдалося запустити фонове відстеження. Перевірте дозволи та BLE-з’єднання.", + "@failedToStartBackgroundTracking": { + "description": "Error message when background tracking fails to start" + }, + "locationBroadcast": "Трансляція місцезнаходження: {latitude}, {longitude}", + "@locationBroadcast": { + "description": "Success message for location broadcast", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "Стандартний PIN для пристроїв без екрана - 123456. Проблеми зі сполученням? Видаліть Bluetooth-пристрій у системних налаштуваннях.", + "@defaultPinInfo": { + "description": "Information about default PIN for pairing" + }, + "noMessagesYet": "Повідомлень ще немає", + "@noMessagesYet": { + "description": "Empty state message when there are no messages" + }, + "pullDownToSync": "Потягніть вниз, щоб синхронізувати повідомлення", + "@pullDownToSync": { + "description": "Instruction to pull down to refresh messages" + }, + "deleteContact": "Видалити контакт", + "@deleteContact": { + "description": "Delete contact action label" + }, + "delete": "Видалити", + "@delete": { + "description": "Delete button label" + }, + "viewOnMap": "Показати на мапі", + "@viewOnMap": { + "description": "Action to view contact location on map" + }, + "refresh": "Оновити", + "@refresh": { + "description": "Refresh button label" + }, + "resetPath": "Скинути маршрут (побудувати заново)", + "@resetPath": { + "description": "Action to reset contact path for re-routing" + }, + "copiedToClipboard": "{label} скопійовано в буфер обміну", + "@copiedToClipboard": { + "description": "Success message when a value is copied to clipboard", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "Будь ласка, введіть пароль", + "@pleaseEnterPassword": { + "description": "Validation message for empty password field" + }, + "failedToSyncContacts": "Не вдалося синхронізувати контакти: {error}", + "@failedToSyncContacts": { + "description": "Error message when contact sync fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "Вхід виконано успішно! Очікування повідомлень кімнати...", + "@loggedInSuccessfully": { + "description": "Success message after successful room login" + }, + "loginFailed": "Помилка входу - неправильний пароль", + "@loginFailed": { + "description": "Error message when room login fails" + }, + "loggingIn": "Вхід до {roomName}...", + "@loggingIn": { + "description": "Status message during room login process", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "Не вдалося надіслати вхід: {error}", + "@failedToSendLogin": { + "description": "Error message when login command fails to send", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "Низька точність геолокації", + "@lowLocationAccuracy": { + "description": "Warning title for low GPS accuracy" + }, + "continue_": "Продовжити", + "@continue_": { + "description": "Continue button label" + }, + "sendSarMarker": "Надіслати маркер SAR", + "@sendSarMarker": { + "description": "Action to send SAR marker" + }, + "deleteDrawing": "Видалити рисунок", + "@deleteDrawing": { + "description": "Action to delete a map drawing" + }, + "drawingTools": "Інструменти малювання", + "@drawingTools": { + "description": "Drawing tools section or menu title" + }, + "drawLine": "Намалювати лінію", + "@drawLine": { + "description": "Map drawing mode: line" + }, + "drawLineDesc": "Намалювати довільну лінію на мапі", + "@drawLineDesc": { + "description": "Description for line drawing mode" + }, + "drawRectangle": "Намалювати прямокутник", + "@drawRectangle": { + "description": "Map drawing mode: rectangle" + }, + "drawRectangleDesc": "Намалювати прямокутну область на мапі", + "@drawRectangleDesc": { + "description": "Description for rectangle drawing mode" + }, + "measureDistance": "Виміряти відстань", + "@measureDistance": { + "description": "Map drawing mode: measure distance" + }, + "measureDistanceDesc": "Затисніть дві точки, щоб виміряти", + "@measureDistanceDesc": { + "description": "Description for distance measurement mode" + }, + "clearMeasurement": "Очистити вимірювання", + "@clearMeasurement": { + "description": "Tooltip to clear measurement" + }, + "distanceLabel": "Відстань: {distance}", + "@distanceLabel": { + "description": "Label showing measured distance", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "Затисніть для другої точки", + "@longPressForSecondPoint": { + "description": "Instruction when first measurement point is set" + }, + "longPressToStartMeasurement": "Затисніть, щоб встановити першу точку", + "@longPressToStartMeasurement": { + "description": "Instruction to start measurement" + }, + "longPressToStartNewMeasurement": "Затисніть, щоб почати нове вимірювання", + "@longPressToStartNewMeasurement": { + "description": "Instruction to restart measurement after completion" + }, + "shareDrawings": "Поділитися рисунками", + "@shareDrawings": { + "description": "Action to share drawings to network" + }, + "clearAllDrawings": "Очистити всі рисунки", + "@clearAllDrawings": { + "description": "Action to clear all local drawings" + }, + "completeLine": "Завершити лінію", + "@completeLine": { + "description": "Tooltip to complete drawing a line" + }, + "broadcastDrawingsToTeam": "Транслювати {count} рисунок{plural} команді", + "@broadcastDrawingsToTeam": { + "description": "Subtitle showing how many drawings will be broadcast", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "Видалити всі {count} рисунок{plural}", + "@removeAllDrawings": { + "description": "Subtitle for remove all drawings action", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "Видалити всі {count} рисунок{plural} з мапи?", + "@deleteAllDrawingsConfirm": { + "description": "Confirmation dialog message for deleting all drawings", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "Рисунок", + "@drawing": { + "description": "Generic drawing label" + }, + "shareDrawingsCount": "Поділитися {count} рисунок{plural}", + "@shareDrawingsCount": { + "description": "Title for share drawings dialog", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "Показати отримані рисунки", + "@showReceivedDrawings": { + "description": "Toggle to show/hide received drawings from other team members" + }, + "showingAllDrawings": "Показуються всі рисунки", + "@showingAllDrawings": { + "description": "Subtitle when received drawings are visible" + }, + "showingOnlyYourDrawings": "Показуються лише ваші рисунки", + "@showingOnlyYourDrawings": { + "description": "Subtitle when received drawings are hidden" + }, + "showSarMarkers": "Показати маркери SAR", + "@showSarMarkers": { + "description": "Toggle to show/hide SAR markers on map" + }, + "showingSarMarkers": "Показуються маркери SAR", + "@showingSarMarkers": { + "description": "Subtitle when SAR markers are visible" + }, + "hidingSarMarkers": "Приховування маркерів SAR", + "@hidingSarMarkers": { + "description": "Subtitle when SAR markers are hidden" + }, + "clearAll": "Очистити все", + "@clearAll": { + "description": "Clear all button label" + }, + "publicChannel": "Публічний канал", + "@publicChannel": { + "description": "Public channel option for sharing" + }, + "broadcastToAll": "Транслювати всім сусіднім вузлам (тимчасово)", + "@broadcastToAll": { + "description": "Description for public channel broadcast" + }, + "storedPermanently": "Постійно збережено в кімнаті", + "@storedPermanently": { + "description": "Description for room storage permanence" + }, + "notConnectedToDevice": "Не підключено до пристрою", + "@notConnectedToDevice": { + "description": "Error message when device is not connected for direct messaging" + }, + "typeYourMessage": "Введіть повідомлення...", + "@typeYourMessage": { + "description": "Placeholder text for message input field" + }, + "quickLocationMarker": "Швидкий маркер місця", + "@quickLocationMarker": { + "description": "Subtitle for SAR marker sheet header" + }, + "markerType": "Тип маркера", + "@markerType": { + "description": "Label for marker type selection section" + }, + "sendTo": "Надіслати до", + "@sendTo": { + "description": "Label for destination selection section" + }, + "noDestinationsAvailable": "Немає доступних отримувачів.", + "@noDestinationsAvailable": { + "description": "Warning when no rooms or channels exist" + }, + "selectDestination": "Виберіть отримувача...", + "@selectDestination": { + "description": "Placeholder for destination dropdown" + }, + "ephemeralBroadcastInfo": "Тимчасове: транслюється лише по радіо. Не зберігається - вузли мають бути онлайн.", + "@ephemeralBroadcastInfo": { + "description": "Information about ephemeral channel broadcasts" + }, + "persistentRoomInfo": "Постійне: незмінно зберігається в кімнаті. Автоматично синхронізується і зберігається офлайн.", + "@persistentRoomInfo": { + "description": "Information about persistent room storage" + }, + "location": "Місцезнаходження", + "@location": { + "description": "Label for location section" + }, + "fromMap": "З мапи", + "@fromMap": { + "description": "Badge showing location is from map tap" + }, + "gettingLocation": "Отримання місцезнаходження...", + "@gettingLocation": { + "description": "Loading message while fetching GPS location" + }, + "locationError": "Помилка геолокації", + "@locationError": { + "description": "Title for location error messages" + }, + "retry": "Повторити", + "@retry": { + "description": "Retry button label" + }, + "refreshLocation": "Оновити місцезнаходження", + "@refreshLocation": { + "description": "Tooltip for refresh location button" + }, + "accuracyMeters": "Точність: ±{accuracy}м", + "@accuracyMeters": { + "description": "Display of GPS accuracy in meters", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "Нотатки (необов’язково)", + "@notesOptional": { + "description": "Label for optional notes field" + }, + "addAdditionalInformation": "Додайте додаткову інформацію...", + "@addAdditionalInformation": { + "description": "Placeholder for notes field" + }, + "lowAccuracyWarning": "Точність місцезнаходження становить ±{accuracy}м. Це може бути недостатньо точно для операцій SAR.\n\nПродовжити все одно?", + "@lowAccuracyWarning": { + "description": "Warning dialog content for low GPS accuracy", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "Увійти до кімнати", + "@loginToRoom": { + "description": "Title for room login dialog" + }, + "enterPasswordInfo": "Введіть пароль для доступу до цієї кімнати. Пароль буде збережено для подальшого використання.", + "@enterPasswordInfo": { + "description": "Information about room password" + }, + "password": "Пароль", + "@password": { + "description": "Password field label" + }, + "enterRoomPassword": "Введіть пароль кімнати", + "@enterRoomPassword": { + "description": "Password field hint" + }, + "loggingInDots": "Вхід...", + "@loggingInDots": { + "description": "Button text while logging in" + }, + "login": "Увійти", + "@login": { + "description": "Login button label" + }, + "failedToAddRoom": "Не вдалося додати кімнату до пристрою: {error}\n\nМожливо, кімната ще не почала трансляцію.\nСпробуйте зачекати, поки вона почне транслюватися.", + "@failedToAddRoom": { + "description": "Error message when adding room fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "Напряму", + "@direct": { + "description": "Direct routing indicator" + }, + "flood": "Широкомовно", + "@flood": { + "description": "Flood routing indicator" + }, + "loggedIn": "Увійшли", + "@loggedIn": { + "description": "Logged in status badge" + }, + "noGpsData": "Немає GPS-даних", + "@noGpsData": { + "description": "Message when GPS data is not available" + }, + "distance": "Відстань", + "@distance": { + "description": "Distance label" + }, + "directPingTimeout": "Час очікування прямого ping вичерпано - повторна спроба для {name} через flooding...", + "@directPingTimeout": { + "description": "Warning when direct ping times out", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "Ping до {name} не вдався - відповіді не отримано", + "@pingFailed": { + "description": "Error message when ping fails", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "Ви впевнені, що хочете видалити \"{name}\"?\n\nЦе видалить контакт і з застосунку, і з пов’язаного радіопристрою.", + "@deleteContactConfirmation": { + "description": "Confirmation message for deleting contact", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "Не вдалося видалити контакт: {error}", + "@failedToRemoveContact": { + "description": "Error message when contact removal fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "Тип", + "@type": { + "description": "Contact type label" + }, + "publicKey": "Публічний ключ", + "@publicKey": { + "description": "Public key label" + }, + "lastSeen": "Востаннє бачили", + "@lastSeen": { + "description": "Last seen label" + }, + "yes": "Так", + "@yes": { + "description": "Yes answer" + }, + "no": "Ні", + "@no": { + "description": "No answer" + }, + "permissions": "Дозволи", + "@permissions": { + "description": "Permissions label" + }, + "telemetry": "Телеметрія", + "@telemetry": { + "description": "Telemetry section header" + }, + "voltage": "Напруга", + "@voltage": { + "description": "Voltage label" + }, + "battery": "Батарея", + "@battery": { + "description": "Battery label" + }, + "temperature": "Температура", + "@temperature": { + "description": "Temperature label" + }, + "humidity": "Вологість", + "@humidity": { + "description": "Humidity label" + }, + "pressure": "Тиск", + "@pressure": { + "description": "Pressure label" + }, + "gpsTelemetry": "GPS (телеметрія)", + "@gpsTelemetry": { + "description": "GPS from telemetry label" + }, + "reLoginToRoom": "Повторно увійти до кімнати", + "@reLoginToRoom": { + "description": "Button to re-login to room" + }, + "heading": "Напрямок", + "@heading": { + "description": "Compass heading label" + }, + "elevation": "Висота", + "@elevation": { + "description": "Elevation/altitude label" + }, + "accuracy": "Точність", + "@accuracy": { + "description": "GPS accuracy label" + }, + "bearing": "Пеленг", + "@bearing": { + "description": "Bearing label in compass" + }, + "direction": "Напрямок", + "@direction": { + "description": "Direction label in compass" + }, + "filterMarkers": "Фільтрувати маркери", + "@filterMarkers": { + "description": "Title for filter markers dialog" + }, + "filterMarkersTooltip": "Фільтрувати маркери", + "@filterMarkersTooltip": { + "description": "Tooltip for filter button" + }, + "contactsFilter": "Контакти", + "@contactsFilter": { + "description": "Filter option for contacts" + }, + "repeatersFilter": "Ретранслятори", + "@repeatersFilter": { + "description": "Filter option for repeaters" + }, + "sarMarkers": "Маркери SAR", + "@sarMarkers": { + "description": "SAR markers section header" + }, + "foundPerson": "Знайдена людина", + "@foundPerson": { + "description": "Found person SAR marker type" + }, + "fire": "Пожежа", + "@fire": { + "description": "Fire SAR marker type" + }, + "stagingArea": "Зона збору", + "@stagingArea": { + "description": "Staging area SAR marker type" + }, + "showAll": "Показати все", + "@showAll": { + "description": "Button to show all filters" + }, + "locationUnavailable": "Місцезнаходження недоступне", + "@locationUnavailable": { + "description": "Message when GPS location is unavailable" + }, + "ahead": "попереду", + "@ahead": { + "description": "Relative bearing direction - ahead" + }, + "degreesRight": "{degrees}° праворуч", + "@degreesRight": { + "description": "Relative bearing direction - right", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "{degrees}° ліворуч", + "@degreesLeft": { + "description": "Relative bearing direction - left", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "Шир.: {latitude} Довг.: {longitude}", + "@latLonFormat": { + "description": "Latitude and longitude display format", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "Контактів ще немає", + "@noContactsYet": { + "description": "Empty state message when there are no contacts" + }, + "connectToDeviceToLoadContacts": "Підключіться до пристрою, щоб завантажити контакти", + "@connectToDeviceToLoadContacts": { + "description": "Instruction to connect device to load contacts" + }, + "teamMembers": "Члени команди", + "@teamMembers": { + "description": "Section header for team members (chat contacts)" + }, + "repeaters": "Ретранслятори", + "@repeaters": { + "description": "Section header for repeater nodes" + }, + "rooms": "Кімнати", + "@rooms": { + "description": "Section header for rooms" + }, + "channels": "Канали", + "@channels": { + "description": "Section header for broadcast channels" + }, + "selectMapLayer": "Вибрати шар мапи", + "@selectMapLayer": { + "description": "Title for map layer selection dialog" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "OpenStreetMap layer name" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "OpenTopoMap layer name" + }, + "esriSatellite": "ESRI Супутник", + "@esriSatellite": { + "description": "ESRI Satellite imagery layer name" + }, + "googleHybrid": "Google Гібрид", + "@googleHybrid": { + "description": "Google Hybrid layer name (satellite + labels)" + }, + "googleRoadmap": "Google Дорожня карта", + "@googleRoadmap": { + "description": "Google Roadmap layer name (street map)" + }, + "googleTerrain": "Google Рельєф", + "@googleTerrain": { + "description": "Google Terrain layer name (topographic)" + }, + "dragToPosition": "Перетягніть у позицію", + "@dragToPosition": { + "description": "Label when dragging a pin on map" + }, + "createSarMarker": "Створити маркер SAR", + "@createSarMarker": { + "description": "Label for creating SAR marker from pin" + }, + "compass": "Компас", + "@compass": { + "description": "Compass title in detailed compass dialog" + }, + "navigationAndContacts": "Навігація та контакти", + "@navigationAndContacts": { + "description": "Subtitle for compass dialog" + }, + "sarAlert": "ТРИВОГА SAR", + "@sarAlert": { + "description": "Label for SAR alert badge on messages" + }, + "textCopiedToClipboard": "Текст скопійовано в буфер обміну", + "@textCopiedToClipboard": { + "description": "Success message when text is copied" + }, + "cannotReplySenderMissing": "Неможливо відповісти: відсутня інформація про відправника", + "@cannotReplySenderMissing": { + "description": "Error when sender info is missing for reply" + }, + "cannotReplyContactNotFound": "Неможливо відповісти: контакт не знайдено", + "@cannotReplyContactNotFound": { + "description": "Error when contact not found for reply" + }, + "copyText": "Копіювати текст", + "@copyText": { + "description": "Option to copy message text to clipboard" + }, + "saveAsTemplate": "Зберегти як шаблон", + "@saveAsTemplate": { + "description": "Option to save SAR message as a reusable template" + }, + "templateSaved": "Шаблон успішно збережено", + "@templateSaved": { + "description": "Success message when SAR template is saved" + }, + "templateAlreadyExists": "Шаблон із цим emoji уже існує", + "@templateAlreadyExists": { + "description": "Error message when trying to save duplicate template" + }, + "deleteMessage": "Видалити повідомлення", + "@deleteMessage": { + "description": "Dialog title for deleting a message" + }, + "deleteMessageConfirmation": "Ви впевнені, що хочете видалити це повідомлення?", + "@deleteMessageConfirmation": { + "description": "Confirmation text for message deletion" + }, + "shareLocation": "Поділитися місцезнаходженням", + "@shareLocation": { + "description": "Option to share SAR marker location" + }, + "shareLocationText": "{markerInfo}\n\nКоординати: {lat}, {lon}\n\nGoogle Maps: {url}", + "@shareLocationText": { + "description": "Formatted text for sharing SAR marker location", + "placeholders": { + "markerInfo": { + "type": "String" + }, + "lat": { + "type": "String" + }, + "lon": { + "type": "String" + }, + "url": { + "type": "String" + } + } + }, + "sarLocationShare": "Місцезнаходження SAR", + "@sarLocationShare": { + "description": "Subject line when sharing SAR marker location" + }, + "justNow": "Щойно", + "@justNow": { + "description": "Time indicator for very recent activity" + }, + "minutesAgo": "{minutes} хв тому", + "@minutesAgo": { + "description": "Time indicator for minutes ago", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "{hours} год тому", + "@hoursAgo": { + "description": "Time indicator for hours ago", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "{days} дн тому", + "@daysAgo": { + "description": "Time indicator for days ago", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "{seconds} с тому", + "@secondsAgo": { + "description": "Time indicator for seconds ago", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "Надсилання...", + "@sending": { + "description": "Delivery status: sending" + }, + "sent": "Надіслано", + "@sent": { + "description": "Delivery status: sent" + }, + "delivered": "Доставлено", + "@delivered": { + "description": "Delivery status: delivered" + }, + "deliveredWithTime": "Доставлено ({time}ms)", + "@deliveredWithTime": { + "description": "Delivery status with round-trip time", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "Помилка", + "@failed": { + "description": "Delivery status: failed" + }, + "broadcast": "Трансляція", + "@broadcast": { + "description": "Delivery status for channel messages (no echoes yet)" + }, + "deliveredToContacts": "Доставлено {delivered}/{total} контактам", + "@deliveredToContacts": { + "description": "Grouped message delivery count", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "Усе доставлено", + "@allDelivered": { + "description": "Status when all recipients received the message" + }, + "recipientDetails": "Деталі отримувача", + "@recipientDetails": { + "description": "Header for expandable recipient list" + }, + "pending": "Очікує", + "@pending": { + "description": "Delivery status: pending/waiting" + }, + "sarMarkerFoundPerson": "Знайдена людина", + "@sarMarkerFoundPerson": { + "description": "SAR marker type: found person" + }, + "sarMarkerFire": "Місце пожежі", + "@sarMarkerFire": { + "description": "SAR marker type: fire" + }, + "sarMarkerStagingArea": "Зона збору", + "@sarMarkerStagingArea": { + "description": "SAR marker type: staging area" + }, + "sarMarkerObject": "Знайдений об’єкт", + "@sarMarkerObject": { + "description": "SAR marker type: object" + }, + "from": "Від", + "@from": { + "description": "Sender label in notifications" + }, + "coordinates": "Координати", + "@coordinates": { + "description": "Coordinates label" + }, + "tapToViewOnMap": "Торкніться, щоб переглянути на мапі", + "@tapToViewOnMap": { + "description": "Notification action text" + }, + "radioSettings": "Налаштування радіо", + "@radioSettings": { + "description": "Section title for radio settings" + }, + "frequencyMHz": "Частота (MHz)", + "@frequencyMHz": { + "description": "Label for radio frequency field" + }, + "bandwidth": "Ширина смуги", + "@bandwidth": { + "description": "Label for bandwidth dropdown" + }, + "spreadingFactor": "Коефіцієнт розширення", + "@spreadingFactor": { + "description": "Label for spreading factor dropdown" + }, + "codingRate": "Швидкість кодування", + "@codingRate": { + "description": "Label for coding rate dropdown" + }, + "txPowerDbm": "Потужність TX (dBm)", + "@txPowerDbm": { + "description": "Label for TX power field" + }, + "maxPowerDbm": "Макс: {power} dBm", + "@maxPowerDbm": { + "description": "Helper text showing maximum TX power", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "Ви", + "@you": { + "description": "Label for the current user in message bubbles" + }, + "exportFailed": "Помилка експорту: {error}", + "@exportFailed": { + "description": "Error message when export fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "Помилка імпорту: {error}", + "@importFailed": { + "description": "Error message when import fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "Невідомо", + "@unknown": { + "description": "Unknown value label" + }, + "onlineLayers": "Онлайн-шари", + "@onlineLayers": { + "description": "Section header for online map layers" + }, + "locationTrail": "Слід місцезнаходження", + "@locationTrail": { + "description": "Location trail title" + }, + "showTrailOnMap": "Показати слід на мапі", + "@showTrailOnMap": { + "description": "Toggle to show/hide trail on map" + }, + "trailVisible": "Слід видно на мапі", + "@trailVisible": { + "description": "Trail visibility status - visible" + }, + "trailHiddenRecording": "Слід прихований (запис триває)", + "@trailHiddenRecording": { + "description": "Trail visibility status - hidden but recording" + }, + "duration": "Тривалість", + "@duration": { + "description": "Duration label" + }, + "points": "Точки", + "@points": { + "description": "Trail points count label" + }, + "clearTrail": "Очистити слід", + "@clearTrail": { + "description": "Button to clear location trail" + }, + "clearTrailQuestion": "Очистити слід?", + "@clearTrailQuestion": { + "description": "Confirmation dialog title" + }, + "clearTrailConfirmation": "Ви впевнені, що хочете очистити поточний слід місцезнаходження? Цю дію не можна скасувати.", + "@clearTrailConfirmation": { + "description": "Confirmation dialog message" + }, + "noTrailRecorded": "Слід ще не записано", + "@noTrailRecorded": { + "description": "Message when no trail exists" + }, + "startTrackingToRecord": "Почніть відстеження місцезнаходження, щоб записати слід", + "@startTrackingToRecord": { + "description": "Instructions to start trail recording" + }, + "trailControls": "Керування слідом", + "@trailControls": { + "description": "Trail controls tooltip" + }, + "model": "Модель", + "@model": { + "description": "Device model label" + }, + "version": "Версія", + "@version": { + "description": "Version label" + }, + "maxContacts": "Макс. контактів", + "@maxContacts": { + "description": "Maximum contacts capacity label" + }, + "maxChannels": "Макс. каналів", + "@maxChannels": { + "description": "Maximum channels capacity label" + }, + "publicInfo": "Публічна інформація", + "@publicInfo": { + "description": "Public information section header" + }, + "useCurrentLocation": "Використати поточне місцезнаходження", + "@useCurrentLocation": { + "description": "Tooltip for use current location button" + }, + "noneUnknown": "Немає/Невідомо", + "@noneUnknown": { + "description": "Device type: none or unknown" + }, + "chatNode": "Вузол чату", + "@chatNode": { + "description": "Device type: chat node" + }, + "repeater": "Ретранслятор", + "@repeater": { + "description": "Device type: repeater" + }, + "roomChannel": "Кімната/Канал", + "@roomChannel": { + "description": "Device type: room or channel" + }, + "typeNumber": "Тип {number}", + "@typeNumber": { + "description": "Generic device type with number", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "Не вдалося зберегти: {error}", + "@failedToSave": { + "description": "Generic error message for save failures", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "Не вдалося отримати місцезнаходження: {error}", + "@failedToGetLocation": { + "description": "Error message when getting location fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "Шаблони SAR", + "@sarTemplates": { + "description": "SAR templates menu title" + }, + "addTemplate": "Додати шаблон", + "@addTemplate": { + "description": "Button to add new SAR template" + }, + "editTemplate": "Редагувати шаблон", + "@editTemplate": { + "description": "Dialog title for editing template" + }, + "deleteTemplate": "Видалити шаблон", + "@deleteTemplate": { + "description": "Action to delete template" + }, + "templateName": "Назва шаблону", + "@templateName": { + "description": "Label for template name field" + }, + "templateNameHint": "напр. Знайдена людина", + "@templateNameHint": { + "description": "Hint text for template name" + }, + "templateEmoji": "Emoji", + "@templateEmoji": { + "description": "Label for template emoji field" + }, + "emojiRequired": "Emoji обов’язковий", + "@emojiRequired": { + "description": "Validation error when emoji field is empty" + }, + "nameRequired": "Назва обов’язкова", + "@nameRequired": { + "description": "Validation error when name field is empty" + }, + "templateDescription": "Опис (необов’язково)", + "@templateDescription": { + "description": "Label for template description field" + }, + "templateDescriptionHint": "Додайте додатковий контекст...", + "@templateDescriptionHint": { + "description": "Hint text for template description" + }, + "templateColor": "Колір", + "@templateColor": { + "description": "Label for template color picker" + }, + "previewFormat": "Попередній перегляд (формат повідомлення SAR)", + "@previewFormat": { + "description": "Label for format preview" + }, + "importFromClipboard": "Імпорт", + "@importFromClipboard": { + "description": "Button to import templates from clipboard" + }, + "exportToClipboard": "Експорт", + "@exportToClipboard": { + "description": "Button to export templates to clipboard" + }, + "deleteTemplateConfirmation": "Видалити шаблон \"{name}\"?", + "@deleteTemplateConfirmation": { + "description": "Confirmation message for template deletion", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "templateAdded": "Шаблон додано", + "@templateAdded": { + "description": "Success message when template is added" + }, + "templateUpdated": "Шаблон оновлено", + "@templateUpdated": { + "description": "Success message when template is updated" + }, + "templateDeleted": "Шаблон видалено", + "@templateDeleted": { + "description": "Success message when template is deleted" + }, + "templatesImported": "{count, plural, =0{Шаблони не імпортовано} =1{Імпортовано 1 шаблон} other{Імпортовано {count} шаблонів}}", + "@templatesImported": { + "description": "Success message after importing templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "templatesExported": "{count, plural, =1{Експортовано 1 шаблон у буфер обміну} other{Експортовано {count} шаблонів у буфер обміну}}", + "@templatesExported": { + "description": "Success message after exporting templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "resetToDefaults": "Скинути до стандартних", + "@resetToDefaults": { + "description": "Action to reset templates to defaults" + }, + "resetToDefaultsConfirmation": "Це видалить усі користувацькі шаблони та відновить 4 стандартні шаблони. Продовжити?", + "@resetToDefaultsConfirmation": { + "description": "Confirmation message for reset to defaults" + }, + "reset": "Скинути", + "@reset": { + "description": "Reset button label" + }, + "resetComplete": "Шаблони скинуто до стандартних", + "@resetComplete": { + "description": "Success message after reset" + }, + "noTemplates": "Немає доступних шаблонів", + "@noTemplates": { + "description": "Message when no templates exist" + }, + "tapAddToCreate": "Торкніться +, щоб створити перший шаблон", + "@tapAddToCreate": { + "description": "Helper text when no templates exist" + }, + "ok": "OK", + "@ok": { + "description": "OK button label" + }, + "locationPermission": "Дозвіл на геолокацію", + "@locationPermission": { + "description": "Location permission label" + }, + "checking": "Перевірка...", + "@checking": { + "description": "Loading state indicator" + }, + "locationPermissionGrantedAlways": "Надано (Завжди)", + "@locationPermissionGrantedAlways": { + "description": "Location permission status: granted always" + }, + "locationPermissionGrantedWhileInUse": "Надано (Під час використання)", + "@locationPermissionGrantedWhileInUse": { + "description": "Location permission status: granted while in use" + }, + "locationPermissionDeniedTapToRequest": "Відхилено - торкніться, щоб запросити", + "@locationPermissionDeniedTapToRequest": { + "description": "Location permission status: denied, user can request" + }, + "locationPermissionPermanentlyDeniedOpenSettings": "Назавжди відхилено - відкрити налаштування", + "@locationPermissionPermanentlyDeniedOpenSettings": { + "description": "Location permission status: permanently denied" + }, + "locationPermissionDialogContent": "Дозвіл на геолокацію назавжди відхилено. Увімкніть його в налаштуваннях пристрою, щоб використовувати GPS та поширення місцезнаходження.", + "@locationPermissionDialogContent": { + "description": "Content for location permission dialog when permanently denied" + }, + "openSettings": "Відкрити налаштування", + "@openSettings": { + "description": "Button to open device settings" + }, + "locationPermissionGranted": "Дозвіл на геолокацію надано!", + "@locationPermissionGranted": { + "description": "Success message when location permission is granted" + }, + "locationPermissionRequiredForGps": "Для GPS-відстеження та поширення місцезнаходження потрібен дозвіл на геолокацію.", + "@locationPermissionRequiredForGps": { + "description": "Info message about location permission requirement" + }, + "locationPermissionAlreadyGranted": "Дозвіл на геолокацію вже надано.", + "@locationPermissionAlreadyGranted": { + "description": "Info message when permission is already granted" + }, + "sarNavyBlue": "SAR Темно-синій", + "@sarNavyBlue": { + "description": "SAR Navy Blue theme name" + }, + "sarNavyBlueDescription": "Професійний/операційний режим", + "@sarNavyBlueDescription": { + "description": "Description for SAR Navy Blue theme" + }, + "selectRecipient": "Вибрати отримувача", + "@selectRecipient": { + "description": "Title for recipient selector sheet" + }, + "broadcastToAllNearby": "Транслювати всім поблизу", + "@broadcastToAllNearby": { + "description": "Subtitle for public channel option" + }, + "searchRecipients": "Пошук отримувачів...", + "@searchRecipients": { + "description": "Placeholder text for recipient search field" + }, + "noContactsFound": "Контактів не знайдено", + "@noContactsFound": { + "description": "Message when no contacts match search" + }, + "noRoomsFound": "Кімнат не знайдено", + "@noRoomsFound": { + "description": "Message when no rooms match search" + }, + "noRecipientsAvailable": "Немає доступних отримувачів", + "@noRecipientsAvailable": { + "description": "Message when no recipients exist (contacts, rooms, or channels)" + }, + "noChannelsFound": "Каналів не знайдено", + "@noChannelsFound": { + "description": "Message when no channels match the search" + }, + "newMessage": "Нове повідомлення", + "@newMessage": { + "description": "Notification title for new message" + }, + "channel": "Канал", + "@channel": { + "description": "Channel label in notifications" + }, + "samplePoliceLead": "Керівник поліції", + "@samplePoliceLead": { + "description": "Sample team member name" + }, + "sampleDroneOperator": "Оператор дрона", + "@sampleDroneOperator": { + "description": "Sample team member name" + }, + "sampleFirefighterAlpha": "Пожежник", + "@sampleFirefighterAlpha": { + "description": "Sample team member name" + }, + "sampleMedicCharlie": "Медик", + "@sampleMedicCharlie": { + "description": "Sample team member name" + }, + "sampleCommandDelta": "Командування", + "@sampleCommandDelta": { + "description": "Sample team member name" + }, + "sampleFireEngine": "Пожежна машина", + "@sampleFireEngine": { + "description": "Sample team member name" + }, + "sampleAirSupport": "Повітряна підтримка", + "@sampleAirSupport": { + "description": "Sample team member name" + }, + "sampleBaseCoordinator": "Координатор бази", + "@sampleBaseCoordinator": { + "description": "Sample team member name" + }, + "channelEmergency": "Надзвичайна ситуація", + "@channelEmergency": { + "description": "Emergency channel name" + }, + "channelCoordination": "Координація", + "@channelCoordination": { + "description": "Coordination channel name" + }, + "channelUpdates": "Оновлення", + "@channelUpdates": { + "description": "Updates channel name" + }, + "sampleTeamMember": "Тестовий член команди", + "@sampleTeamMember": { + "description": "Sample sender name" + }, + "sampleScout": "Тестовий розвідник", + "@sampleScout": { + "description": "Sample sender name" + }, + "sampleBase": "Тестова база", + "@sampleBase": { + "description": "Sample sender name" + }, + "sampleSearcher": "Тестовий пошуковець", + "@sampleSearcher": { + "description": "Sample sender name" + }, + "sampleObjectBackpack": " Знайдено рюкзак - синього кольору", + "@sampleObjectBackpack": { + "description": "Sample object note" + }, + "sampleObjectVehicle": " Покинутий транспорт - перевірити власника", + "@sampleObjectVehicle": { + "description": "Sample object note" + }, + "sampleObjectCamping": " Знайдено туристичне спорядження", + "@sampleObjectCamping": { + "description": "Sample object note" + }, + "sampleObjectTrailMarker": " Знайдено маркер стежки поза маршрутом", + "@sampleObjectTrailMarker": { + "description": "Sample object note" + }, + "sampleMsgAllTeamsCheckIn": "Усі команди - дайте відмітку", + "@sampleMsgAllTeamsCheckIn": { + "description": "Sample channel message" + }, + "sampleMsgWeatherUpdate": "Оновлення погоди: ясно, температура 18°C", + "@sampleMsgWeatherUpdate": { + "description": "Sample channel message" + }, + "sampleMsgBaseCamp": "Базовий табір розгорнуто в зоні збору", + "@sampleMsgBaseCamp": { + "description": "Sample channel message" + }, + "sampleMsgTeamAlpha": "Команда рухається до сектора 2", + "@sampleMsgTeamAlpha": { + "description": "Sample channel message" + }, + "sampleMsgRadioCheck": "Перевірка радіо - усім станціям відповісти", + "@sampleMsgRadioCheck": { + "description": "Sample channel message" + }, + "sampleMsgWaterSupply": "Запас води доступний на контрольній точці 3", + "@sampleMsgWaterSupply": { + "description": "Sample channel message" + }, + "sampleMsgTeamBravo": "Команда доповідає: сектор 1 чистий", + "@sampleMsgTeamBravo": { + "description": "Sample channel message" + }, + "sampleMsgEtaRallyPoint": "Орієнтовний час до точки збору: 15 хвилин", + "@sampleMsgEtaRallyPoint": { + "description": "Sample channel message" + }, + "sampleMsgSupplyDrop": "Скидання постачання підтверджено на 14:00", + "@sampleMsgSupplyDrop": { + "description": "Sample channel message" + }, + "sampleMsgDroneSurvey": "Обстеження дроном завершено - результатів немає", + "@sampleMsgDroneSurvey": { + "description": "Sample channel message" + }, + "sampleMsgTeamCharlie": "Команда просить підкріплення", + "@sampleMsgTeamCharlie": { + "description": "Sample channel message" + }, + "sampleMsgRadioDiscipline": "Усім підрозділам: дотримуйтеся радіодисципліни", + "@sampleMsgRadioDiscipline": { + "description": "Sample channel message" + }, + "sampleMsgUrgentMedical": "ТЕРМІНОВО: потрібна медична допомога в секторі 4", + "@sampleMsgUrgentMedical": { + "description": "Sample emergency message" + }, + "sampleMsgAdultMale": " Дорослий чоловік, при свідомості", + "@sampleMsgAdultMale": { + "description": "Sample emergency message note" + }, + "sampleMsgFireSpotted": "Помічено пожежу - координати надходять", + "@sampleMsgFireSpotted": { + "description": "Sample emergency message" + }, + "sampleMsgSpreadingRapidly": " Швидко поширюється!", + "@sampleMsgSpreadingRapidly": { + "description": "Sample emergency message note" + }, + "sampleMsgPriorityHelicopter": "ПРІОРИТЕТ: потрібна підтримка гелікоптера", + "@sampleMsgPriorityHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgMedicalTeamEnRoute": "Медична команда прямує до вашого місця", + "@sampleMsgMedicalTeamEnRoute": { + "description": "Sample emergency message" + }, + "sampleMsgEvacHelicopter": "ETA евакуаційного гелікоптера 10 хвилин", + "@sampleMsgEvacHelicopter": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyResolved": "Надзвичайну ситуацію вирішено - все чисто", + "@sampleMsgEmergencyResolved": { + "description": "Sample emergency message" + }, + "sampleMsgEmergencyStagingArea": " Аварійна зона збору", + "@sampleMsgEmergencyStagingArea": { + "description": "Sample emergency message note" + }, + "sampleMsgEmergencyServices": "Екстрені служби повідомлені та вже реагують", + "@sampleMsgEmergencyServices": { + "description": "Sample emergency message" + }, + "sampleAlphaTeamLead": "Керівник команди", + "@sampleAlphaTeamLead": { + "description": "Sample team name" + }, + "sampleBravoScout": "Розвідник", + "@sampleBravoScout": { + "description": "Sample team name" + }, + "sampleCharlieMedic": "Медик", + "@sampleCharlieMedic": { + "description": "Sample team name" + }, + "sampleDeltaNavigator": "Навігатор", + "@sampleDeltaNavigator": { + "description": "Sample team name" + }, + "sampleEchoSupport": "Підтримка", + "@sampleEchoSupport": { + "description": "Sample team name" + }, + "sampleBaseCommand": "Командування бази", + "@sampleBaseCommand": { + "description": "Sample team name" + }, + "sampleFieldCoordinator": "Польовий координатор", + "@sampleFieldCoordinator": { + "description": "Sample team name" + }, + "sampleMedicalTeam": "Медична команда", + "@sampleMedicalTeam": { + "description": "Sample team name" + }, + "mapDrawing": "Рисунок на мапі", + "@mapDrawing": { + "description": "Label for map drawing messages" + }, + "navigateToDrawing": "Перейти до рисунка", + "@navigateToDrawing": { + "description": "Option to navigate to drawing on map" + }, + "copyCoordinates": "Копіювати координати", + "@copyCoordinates": { + "description": "Option to copy coordinates to clipboard" + }, + "hideFromMap": "Приховати з мапи", + "@hideFromMap": { + "description": "Option to hide drawing from map" + }, + "lineDrawing": "Лінійний рисунок", + "@lineDrawing": { + "description": "Label for line type drawings" + }, + "rectangleDrawing": "Рисунок прямокутника", + "@rectangleDrawing": { + "description": "Label for rectangle type drawings" + }, + "manualCoordinates": "Ручні координати", + "@manualCoordinates": { + "description": "Label for manual coordinate input toggle" + }, + "enterCoordinatesManually": "Введіть координати вручну", + "@enterCoordinatesManually": { + "description": "Description for manual coordinate input option" + }, + "latitudeLabel": "Широта", + "@latitudeLabel": { + "description": "Label for latitude input field" + }, + "longitudeLabel": "Довгота", + "@longitudeLabel": { + "description": "Label for longitude input field" + }, + "exampleCoordinates": "Приклад: 46.0569, 14.5058", + "@exampleCoordinates": { + "description": "Example coordinate format hint" + }, + "shareDrawing": "Поділитися рисунком", + "@shareDrawing": { + "description": "Title for share single drawing dialog" + }, + "shareWithAllNearbyDevices": "Поділитися з усіма пристроями поблизу", + "@shareWithAllNearbyDevices": { + "description": "Subtitle for public channel sharing option" + }, + "shareToRoom": "Поділитися в кімнату", + "@shareToRoom": { + "description": "Header for room sharing section" + }, + "sendToPersistentStorage": "Надіслати в постійне сховище кімнати", + "@sendToPersistentStorage": { + "description": "Subtitle for room sharing option" + }, + "deleteDrawingConfirm": "Ви впевнені, що хочете видалити цей рисунок?", + "@deleteDrawingConfirm": { + "description": "Confirmation message for deleting a single drawing" + }, + "drawingDeleted": "Рисунок видалено", + "@drawingDeleted": { + "description": "Success message after deleting a drawing" + }, + "yourDrawingsCount": "Ваші рисунки ({count})", + "@yourDrawingsCount": { + "description": "Header showing count of user's drawings", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shared": "Спільний", + "@shared": { + "description": "Status label for shared drawings" + }, + "line": "Лінія", + "@line": { + "description": "Line drawing type label" + }, + "rectangle": "Прямокутник", + "@rectangle": { + "description": "Rectangle drawing type label" + }, + "updateAvailable": "Доступне оновлення", + "@updateAvailable": { + "description": "Title for update dialog when new version is available" + }, + "currentVersion": "Поточна", + "@currentVersion": { + "description": "Label for current app version" + }, + "latestVersion": "Остання", + "@latestVersion": { + "description": "Label for latest available app version" + }, + "downloadUpdate": "Завантажити", + "@downloadUpdate": { + "description": "Button to download app update" + }, + "updateLater": "Пізніше", + "@updateLater": { + "description": "Button to dismiss update dialog" + }, + "cadastralParcels": "Кадастрові ділянки", + "@cadastralParcels": { + "description": "Label for cadastral parcels WMS overlay layer" + }, + "forestRoads": "Лісові дороги", + "@forestRoads": { + "description": "Label for forest roads WMS overlay layer" + }, + "wmsOverlays": "Накладки WMS", + "@wmsOverlays": { + "description": "Section header for WMS overlay layers in layer selector" + }, + "hikingTrails": "Пішохідні стежки", + "@hikingTrails": { + "description": "Label for hiking/mountain trails WMS overlay layer" + }, + "mainRoads": "Основні дороги", + "@mainRoads": { + "description": "Label for main roads WMS overlay layer" + }, + "houseNumbers": "Номери будинків", + "@houseNumbers": { + "description": "Label for house numbers WMS overlay layer" + }, + "fireHazardZones": "Зони пожежної небезпеки", + "@fireHazardZones": { + "description": "Label for fire hazard risk zones WMS overlay layer" + }, + "historicalFires": "Історичні пожежі", + "@historicalFires": { + "description": "Label for historical forest fires WMS overlay layer" + }, + "firebreaks": "Протипожежні смуги", + "@firebreaks": { + "description": "Label for firebreaks WMS overlay layer" + }, + "krasFireZones": "Пожежні зони Красу", + "@krasFireZones": { + "description": "Label for Kras fire zones WMS overlay layer" + }, + "placeNames": "Назви місць", + "@placeNames": { + "description": "Label for geographic place names WMS overlay layer" + }, + "municipalityBorders": "Межі громад", + "@municipalityBorders": { + "description": "Label for municipality borders WMS overlay layer" + }, + "topographicMap": "Топографічна мапа 1:25000", + "@topographicMap": { + "description": "Label for DTK25 topographic base map layer" + }, + "recentMessages": "Останні повідомлення", + "@recentMessages": { + "description": "Header for recent messages overlay on map in fullscreen mode" + }, + "addChannel": "Додати канал", + "@addChannel": { + "description": "Button to add a new channel" + }, + "channelName": "Назва каналу", + "@channelName": { + "description": "Label for channel name field" + }, + "channelNameHint": "напр. Рятувальна команда Альфа", + "@channelNameHint": { + "description": "Hint for channel name field" + }, + "channelSecret": "Секрет каналу", + "@channelSecret": { + "description": "Label for channel secret field" + }, + "channelSecretHint": "Спільний пароль для цього каналу", + "@channelSecretHint": { + "description": "Hint for channel secret field" + }, + "channelSecretHelp": "Цей секрет має бути спільним для всіх членів команди, яким потрібен доступ до цього каналу", + "@channelSecretHelp": { + "description": "Help text explaining channel secret" + }, + "channelTypesInfo": "Hash-канали (#team): секрет автоматично генерується з назви. Однакова назва = той самий канал на всіх пристроях.\n\nПриватні канали: використовуйте явний секрет. Приєднатися можуть лише ті, хто його знає.", + "@channelTypesInfo": { + "description": "Information banner explaining hash and private channel types" + }, + "hashChannelInfo": "Hash-канал: секрет буде автоматично згенерований з назви каналу. Кожен, хто використовує ту саму назву, приєднається до того самого каналу.", + "@hashChannelInfo": { + "description": "Help text for hash channels (# prefix)" + }, + "channelNameRequired": "Назва каналу обов’язкова", + "@channelNameRequired": { + "description": "Validation error for empty channel name" + }, + "channelNameTooLong": "Назва каналу має бути не довшою за 31 символ", + "@channelNameTooLong": { + "description": "Validation error for channel name too long" + }, + "channelSecretRequired": "Секрет каналу обов’язковий", + "@channelSecretRequired": { + "description": "Validation error for empty channel secret" + }, + "channelSecretTooLong": "Секрет каналу має бути не довшим за 32 символи", + "@channelSecretTooLong": { + "description": "Validation error for channel secret too long" + }, + "invalidAsciiCharacters": "Дозволені лише символи ASCII", + "@invalidAsciiCharacters": { + "description": "Validation error for non-ASCII characters" + }, + "channelCreatedSuccessfully": "Канал успішно створено", + "@channelCreatedSuccessfully": { + "description": "Success message after creating channel" + }, + "channelCreationFailed": "Не вдалося створити канал: {error}", + "@channelCreationFailed": { + "description": "Error message when channel creation fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deleteChannel": "Видалити канал", + "@deleteChannel": { + "description": "Delete channel button/menu item" + }, + "deleteChannelConfirmation": "Ви впевнені, що хочете видалити канал \"{channelName}\"? Цю дію не можна скасувати.", + "@deleteChannelConfirmation": { + "description": "Confirmation dialog when deleting a channel", + "placeholders": { + "channelName": { + "type": "String" + } + } + }, + "channelDeletedSuccessfully": "Канал успішно видалено", + "@channelDeletedSuccessfully": { + "description": "Success message after deleting channel" + }, + "channelDeletionFailed": "Не вдалося видалити канал: {error}", + "@channelDeletionFailed": { + "description": "Error message when channel deletion fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "createChannel": "Створити канал", + "@createChannel": { + "description": "Button text for creating a channel" + }, + "wizardBack": "Назад", + "@wizardBack": { + "description": "Wizard back button text" + }, + "wizardSkip": "Пропустити", + "@wizardSkip": { + "description": "Wizard skip button text" + }, + "wizardNext": "Далі", + "@wizardNext": { + "description": "Wizard next button text" + }, + "wizardGetStarted": "Почати", + "@wizardGetStarted": { + "description": "Wizard final button text to complete onboarding" + }, + "wizardWelcomeTitle": "Ласкаво просимо до MeshCore SAR", + "@wizardWelcomeTitle": { + "description": "Welcome wizard first page title" + }, + "viewWelcomeTutorial": "Переглянути вступний посібник", + "@viewWelcomeTutorial": { + "description": "Settings option to re-show welcome wizard" + }, + "allTeamContacts": "Усі контакти команди", + "@allTeamContacts": { + "description": "Destination option to send SAR marker to all team contacts" + }, + "directMessagesInfo": "Прямі повідомлення з ACK. Надіслано {count} членам команди.", + "@directMessagesInfo": { + "description": "Information about sending to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "sarMarkerSentToContacts": "Маркер SAR надіслано {count} контактам", + "@sarMarkerSentToContacts": { + "description": "Success message after sending SAR marker to all contacts", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noContactsAvailable": "Немає доступних контактів команди", + "@noContactsAvailable": { + "description": "Message when there are no chat contacts to send to" + }, + "reply": "Відповісти", + "@reply": {}, + "technicalDetails": "Технічні деталі", + "@technicalDetails": {}, + "messageTechnicalDetails": "Технічні деталі повідомлення", + "@messageTechnicalDetails": {}, + "linkQuality": "Якість зв’язку", + "@linkQuality": {}, + "delivery": "Доставка", + "@delivery": {}, + "status": "Статус", + "@status": {}, + "expectedAckTag": "Очікуваний тег ACK", + "@expectedAckTag": {}, + "roundTrip": "Час в обидва боки", + "@roundTrip": {}, + "retryAttempt": "Спроба повтору", + "@retryAttempt": {}, + "floodFallback": "Резервний flooding", + "@floodFallback": {}, + "identity": "Ідентичність", + "@identity": {}, + "messageId": "ID повідомлення", + "@messageId": {}, + "sender": "Відправник", + "@sender": {}, + "senderKey": "Ключ відправника", + "@senderKey": {}, + "recipient": "Отримувач", + "@recipient": {}, + "recipientKey": "Ключ отримувача", + "@recipientKey": {}, + "voice": "Голос", + "@voice": {}, + "voiceId": "ID голосу", + "@voiceId": {}, + "envelope": "Конверт", + "@envelope": {}, + "sessionProgress": "Прогрес сеансу", + "@sessionProgress": {}, + "complete": "Завершено", + "@complete": {}, + "rawDump": "Сирий дамп", + "@rawDump": {}, + "cannotRetryMissingRecipient": "Неможливо повторити: відсутня інформація про отримувача", + "@cannotRetryMissingRecipient": {}, + "voiceUnavailable": "Голос зараз недоступний", + "@voiceUnavailable": {}, + "requestingVoice": "Запит голосу", + "@requestingVoice": {}, + "device": "пристрій", + "change": "Змінити", + "wizardOverviewDescription": "Ця програма поєднує повідомлення MeshCore, польові оновлення SAR, карти та інструменти пристрою в одному місці.", + "wizardOverviewFeature1": "Надсилайте особисті повідомлення, повідомлення кімнат і повідомлення каналів з основної вкладки «Повідомлення».", + "wizardOverviewFeature2": "Діліться SAR-маркерами, малюнками на карті, голосовими кліпами та зображеннями через mesh-мережу.", + "wizardOverviewFeature3": "Підключайтеся через BLE або TCP, а потім керуйте супутнім радіопристроєм прямо з програми.", + "wizardMessagingTitle": "Повідомлення та польові звіти", + "wizardMessagingDescription": "Повідомлення тут це більше, ніж просто текст. Програма вже підтримує кілька типів операційних даних і сценаріїв передачі.", + "wizardMessagingFeature1": "Надсилайте особисті повідомлення, повідомлення кімнат і трафік каналів з одного редактора.", + "wizardMessagingFeature2": "Створюйте оновлення SAR і багаторазові шаблони SAR для типових польових звітів.", + "wizardMessagingFeature3": "Передавайте голосові сесії та зображення з індикатором прогресу й оцінками ефірного часу в інтерфейсі.", + "wizardConnectDeviceTitle": "Підключити пристрій", + "wizardConnectDeviceDescription": "Підключіть своє радіо MeshCore, виберіть назву та застосуйте радіопрофіль перед продовженням.", + "wizardSetupBadge": "Налаштування", + "wizardOverviewBadge": "Огляд", + "wizardConnectedToDevice": "Підключено до {deviceName}", + "wizardNoDeviceConnected": "Ще немає підключеного пристрою", + "wizardSkipForNow": "Пропустити поки що", + "wizardDeviceNameLabel": "Назва пристрою", + "wizardDeviceNameHelp": "Ця назва оголошується іншим користувачам MeshCore.", + "wizardConfigRegionLabel": "Регіон конфігурації", + "wizardConfigRegionHelp": "Використовується повний офіційний список профілів MeshCore. Типово вибрано EU/UK (Narrow).", + "wizardPresetNote1": "Переконайтеся, що вибраний профіль відповідає місцевим радіоправилам.", + "wizardPresetNote2": "Список відповідає офіційному потоку профілів інструмента MeshCore config.", + "wizardPresetNote3": "Для онбордингу типовим залишається EU/UK (Narrow).", + "wizardSaving": "Збереження...", + "wizardSaveAndContinue": "Зберегти й продовжити", + "wizardEnterDeviceName": "Введіть назву пристрою перед продовженням.", + "wizardDeviceSetupSaved": "Збережено {deviceName} з профілем {presetName}.", + "wizardNetworkTitle": "Контакти, кімнати та ретранслятори", + "wizardNetworkDescription": "Вкладка «Контакти» організовує мережу, яку ви виявляєте, і маршрути, які вивчаєте з часом.", + "wizardNetworkFeature1": "Переглядайте членів команди, ретранслятори, кімнати, канали та оголошення в очікуванні в одному списку.", + "wizardNetworkFeature2": "Використовуйте smart ping, вхід до кімнат, вивчені шляхи й інструменти скидання маршрутів, коли зв'язок стає нестабільним.", + "wizardNetworkFeature3": "Створюйте канали та керуйте мережевими призначеннями, не виходячи з програми.", + "wizardMapOpsTitle": "Мапа, сліди та спільна геометрія", + "wizardMapOpsDescription": "Мапа програми напряму пов'язана з повідомленнями, відстеженням і SAR-накладками, а не є окремим переглядачем.", + "wizardMapOpsFeature1": "Відстежуйте власну позицію, місця розташування команди та сліди руху на мапі.", + "wizardMapOpsFeature2": "Відкривайте малюнки з повідомлень, переглядайте їх в інтерфейсі та видаляйте з мапи за потреби.", + "wizardMapOpsFeature3": "Використовуйте мапи ретрансляторів і спільні накладки, щоб розуміти покриття мережі в полі.", + "wizardToolsTitle": "Інструменти поза повідомленнями", + "wizardToolsDescription": "Тут є більше, ніж чотири основні вкладки. Програма також включає налаштування, діагностику та необов'язкові сценарії датчиків.", + "wizardToolsFeature1": "Відкрийте налаштування пристрою, щоб змінити параметри радіо, телеметрію, потужність TX і дані супутнього пристрою.", + "wizardToolsFeature2": "Увімкніть вкладку «Датчики», коли потрібні панелі моніторингу та швидкі дії оновлення.", + "wizardToolsFeature3": "Використовуйте журнали пакетів, сканування спектра та діагностику розробника для усунення проблем mesh-мережі.", + "contactInSensors": "У датчиках", + "contactAddToSensors": "Додати до датчиків", + "contactSetPath": "Задати шлях", + "contactAddedToSensors": "{contactName} додано до Датчиків", + "contactFailedToClearRoute": "Не вдалося очистити маршрут: {error}", + "contactRouteCleared": "Маршрут очищено", + "contactRouteSet": "Маршрут задано: {route}", + "contactFailedToSetRoute": "Не вдалося задати маршрут: {error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "Тайм-аут ACK", + "opcode": "Опкод", + "payload": "Корисне навантаження", + "hops": "Хопи", + "hashSize": "Розмір хешу", + "pathBytes": "Байти шляху", + "selectedPath": "Обраний шлях", + "estimatedTx": "Очікувана передача", + "senderToReceipt": "Від відправника до отримання", + "receivedCopies": "Отримані копії", + "retryCause": "Причина повтору", + "retryMode": "Режим повтору", + "retryResult": "Результат повтору", + "lastRetry": "Остання спроба", + "rxPackets": "RX-пакети", + "mesh": "Mesh", + "rate": "Швидкість", + "window": "Вікно", + "posttxDelay": "Затримка після передачі", + "bandpass": "Смуговий", + "bandpassFilterVoice": "Смуговий фільтр голосу", + "active": "Активно", + "addContact": "Додати контакт", + "all": "Усі", + "clearAllLabel": "Очистити все", + "clearFilters": "Очистити фільтри", + "clearRoute": "Очистити маршрут", + "clearMessages": "Очистити повідомлення", + "clearScale": "Очистити масштаб", + "clearDiscoveries": "Очистити виявлення", + "clearOnlineTraceDatabase": "Очистити базу трасувань", + "clearAllChannels": "Очистити всі канали", + "clearAllContacts": "Очистити всі контакти", + "clearChannels": "Очистити канали", + "clearContacts": "Очистити контакти", + "clearPathOnMaxRetry": "Очистити шлях при макс. спробі", + "create": "Створити", + "custom": "Користувацький", + "defaultValue": "За замовчуванням", + "duplicate": "Дублювати", + "editName": "Редагувати ім'я", + "open": "Відкрити", + "paste": "Вставити", + "preview": "Попередній перегляд", + "remove": "Видалити", + "rename": "Перейменувати", + "resolveAll": "Вирішити все", + "send": "Надіслати", + "sendAnyway": "Надіслати все одно", + "share": "Поділитися", + "trace": "Трасування", + "discovery": "Виявлення", + "discoverRepeaters": "Виявити повторювачі", + "discoverSensors": "Виявити датчики", + "repeaterDiscoverySent": "Виявлення повторювачів надіслано", + "sensorDiscoverySent": "Виявлення датчиків надіслано", + "clearedPendingDiscoveries": "Очікувані виявлення очищено.", + "autoDiscovery": "Автоматичне виявлення", + "enableAutomaticAdding": "Увімкнути автоматичне додавання", + "autoaddRepeaters": "Автододавання повторювачів", + "autoaddRoomServers": "Автододавання серверів кімнат", + "autoaddSensors": "Автододавання датчиків", + "autoaddUsers": "Автододавання користувачів", + "overwriteOldestWhenFull": "Перезаписати найстаріші при заповненні", + "storage": "Сховище", + "dangerZone": "Небезпечна зона", + "profiles": "Профілі", + "favourites": "Обране", + "sensors": "Датчики", + "others": "Інші", + "gpsModule": "Модуль GPS", + "liveTraffic": "Живий трафік", + "repeatersMap": "Карта повторювачів", + "blePacketLogs": "Журнали BLE-пакетів", + "onlineTraceDatabase": "База трасувань", + "routePathByteSize": "Розмір шляху в байтах", + "messageNotifications": "Сповіщення про повідомлення", + "sarAlerts": "SAR-сповіщення", + "discoveryNotifications": "Сповіщення про виявлення", + "updateNotifications": "Сповіщення про оновлення", + "muteWhileAppIsOpen": "Без звуку при відкритому додатку", + "disableContacts": "Вимкнути контакти", + "enableSensorsTab": "Увімкнути вкладку Датчики", + "enableProfiles": "Увімкнути профілі", + "nearestRepeaterFallback": "Найближчий повторювач як резерв", + "deleteAllStoredMessageHistory": "Видалити всю історію повідомлень", + "messageFontSize": "Розмір шрифту повідомлень", + "rotateMapWithHeading": "Обертати карту за напрямком", + "showMapDebugInfo": "Показати налагоджувальну інфо карти", + "openMapInFullscreen": "Відкрити карту на повний екран", + "showSarMarkersLabel": "Показати SAR-маркери", + "hideRepeatersOnMap": "Сховати повторювачі на карті", + "setMapScale": "Встановити масштаб карти", + "customMapScaleSaved": "Користувацький масштаб карти збережено", + "voiceBitrate": "Бітрейт голосу", + "voiceCompressor": "Компресор голосу", + "voiceLimiter": "Лімітер голосу", + "micAutoGain": "Автопідсилення мікрофона", + "echoCancellation": "Придушення ехо", + "noiseSuppression": "Придушення шуму", + "trimSilenceInVoiceMessages": "Обрізати тишу в голосових повідомленнях", + "compressor": "Компресор", + "limiter": "Лімітер", + "autoGain": "Автопідсилення", + "echoCancel": "Ехо", + "noiseSuppress": "Шум", + "silenceTrim": "Тиша", + "maxImageSize": "Максимальний розмір зображення", + "imageCompression": "Стиснення зображення", + "grayscale": "Відтінки сірого", + "ultraMode": "Ультра режим", + "fastPrivateGpsUpdates": "Швидкі приватні GPS-оновлення", + "movementThreshold": "Поріг руху", + "fastGpsMovementThreshold": "Поріг руху швидкого GPS", + "fastGpsActiveuseInterval": "Інтервал активного використання швидкого GPS", + "activeuseUpdateInterval": "Інтервал оновлення при активному використанні", + "repeatNearbyTraffic": "Повторювати близький трафік", + "relayThroughRepeatersAcrossTheMesh": "Ретранслювати через повторювачі по мережі", + "nearbyOnlyWithoutRepeaterFlooding": "Лише поблизу, без flood повторювачів", + "multihop": "Багатохоповий", + "createProfile": "Створити профіль", + "renameProfile": "Перейменувати профіль", + "newProfile": "Новий профіль", + "manageProfiles": "Керувати профілями", + "enableProfilesToStartManagingThem": "Увімкніть профілі, щоб почати керувати ними.", + "openMessage": "Відкрити повідомлення", + "jumpToTheRelatedSarMessage": "Перейти до пов'язаного SAR-повідомлення", + "removeSarMarker": "Видалити SAR-маркер", + "pleaseSelectADestinationToSendSarMarker": "Виберіть призначення для надсилання SAR-маркера", + "sarMarkerBroadcastToPublicChannel": "SAR-маркер надіслано на публічний канал", + "sarMarkerSentToRoom": "SAR-маркер надіслано в кімнату", + "loadFromGallery": "Завантажити з галереї", + "replaceImage": "Замінити зображення", + "selectFromGallery": "Вибрати з галереї", + "team": "Команда", + "found": "Знайдено", + "staging": "Місце збору", + "object": "Об'єкт", + "quiet": "Тихо", + "busy": "Зайнято", + "searchMessages": "Пошук повідомлень", + "sendImageFromGallery": "Надіслати зображення з галереї", + "takePhoto": "Зробити фото", + "allMessages": "Усі повідомлення", + "sendToPublicChannel": "Надіслати в публічний канал?", + "selectMarkerTypeAndDestination": "Виберіть тип маркера та призначення", + "noDestinationsAvailableLabel": "Немає доступних призначень", + "image": "Зображення", + "format": "Формат", + "dimensions": "Розміри", + "segments": "Сегменти", + "transfers": "Передачі", + "downloadedBy": "Завантажено", + "saveDiscoverySettings": "Зберегти налаштування виявлення", + "savePublicInfo": "Зберегти публічну інформацію", + "saveRadioSettings": "Зберегти налаштування радіо", + "savePath": "Зберегти шлях", + "wipeDeviceData": "Стерти дані пристрою", + "wipeDevice": "Стерти пристрій", + "destructiveDeviceActions": "Деструктивні дії пристрою.", + "chooseAPresetOrFinetuneCustomRadioSettings": "Оберіть пресет або налаштуйте радіо вручну.", + "used": "Використано", + "total": "Загалом", + "renameValue": "Перейменувати значення", + "customizeFields": "Налаштувати поля", + "livePreview": "Попередній перегляд наживо", + "refreshSchedule": "Розклад оновлення", + "noResponse": "Немає відповіді", + "refreshing": "Оновлення", + "unavailable": "Недоступно", + "pickARelayOrNodeToWatchInSensors": "Оберіть реле або вузол для спостереження.", + "publicKeyLabel": "Публічний ключ", + "alreadyInContacts": "Вже в контактах", + "connectToADeviceBeforeAddingContacts": "Підключіться до пристрою перед додаванням контактів", + "fromContacts": "З контактів", + "onlineOnly": "Лише онлайн", + "inBoth": "В обох", + "source": "Джерело", + "allMessagesCleared": "Усі повідомлення очищено", + "onlineTraceDatabaseCleared": "Базу трасувань очищено", + "packetLogsCleared": "Журнали пакетів очищено", + "hexDataCopiedToClipboard": "Hex-дані скопійовано в буфер", + "developerModeEnabled": "Режим розробника увімкнено", + "developerModeDisabled": "Режим розробника вимкнено", + "clipboardIsEmpty": "Буфер обміну порожній", + "contactImported": "Контакт імпортовано", + "contactLinkCopiedToClipboard": "Посилання на контакт скопійовано в буфер", + "failedToExportContact": "Не вдалося експортувати контакт", + "noLogsToExport": "Немає журналів для експорту", + "exportAsCsv": "Експортувати як CSV", + "exportAsText": "Експортувати як текст", + "receivedRfc3339": "Отримано (RFC3339)", + "buildTime": "Час збірки", + "downloadUrlNotAvailable": "URL завантаження недоступний", + "cannotOpenDownloadUrl": "Не вдається відкрити URL завантаження", + "updateCheckIsOnlyAvailableOnAndroid": "Перевірка оновлень доступна лише на Android", + "youAreRunningTheLatestVersion": "Ви використовуєте останню версію", + "updateAvailableButDownloadUrlNotFound": "Оновлення доступне, але URL завантаження не знайдено", + "startTictactoe": "Почати Tic-Tac-Toe", + "tictactoeUnavailable": "Tic-Tac-Toe недоступно", + "tictactoeOpponentUnknown": "Tic-Tac-Toe: суперник невідомий", + "tictactoeWaitingForStart": "Tic-Tac-Toe: очікування початку", + "acceptsShareLinks": "Приймає посилання для обміну", + "supportsRawHex": "Підтримує raw hex", + "clipboardfriendly": "Зручно для буфера", + "captured": "Захоплено", + "size": "Розмір", + "noCustomChannelsToClear": "Немає користувацьких каналів для очищення.", + "noDeviceContactsToClear": "Немає контактів пристрою для очищення.", + "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": "Область регіону очищена", + "simpleMode": "Простий режим", + "simpleModeDescription": "Лише чат і карта з обраними контактами", + "simpleModeRequiresLockedDestination": "Спочатку увімкніть «Зафіксувати призначення» з каналом, щоб використовувати простий режим", + "noFavouriteContacts": "Немає обраних контактів", + "selectDestinationFirst": "Спершу виберіть канал, контакт або кімнату", + "recipientNoLongerAvailable": "Одержувач більше недоступний", + "failedToSendMessage": "Не вдалося надіслати: {error}", + "tictactoeDirectOnly": "Хрестики-нулики працюють лише в особистих повідомленнях. Спершу виберіть контакт.", + "deviceKeyUnavailable": "Ключ пристрою недоступний", + "imageCompressionFailed": "Не вдалося стиснути зображення", + "imageFragmentationFailed": "Не вдалося розділити зображення на фрагменти", + "failedToAnnounceImage": "Не вдалося анонсувати зображення", + "noRecipientSelected": "Одержувача не вибрано", + "imageSendFailed": "Не вдалося надіслати зображення", + "microphonePermissionRequiredForVoice": "Для голосових повідомлень потрібен дозвіл на мікрофон", + "recordingDiscarded": "Запис відхилено", + "publicChannelMediaWarning": "Ви збираєтеся надіслати {mediaType} в публічний канал. Це не рекомендується: отримати це може будь-хто в mesh-мережі. Виберіть приватний або тегований канал, якщо тільки це не те, що ви хочете.", + "selectPrivateChannelToShareLocation": "Виберіть приватний канал, щоб поділитися місцезнаходженням.", + "stopSharingMyLocation": "Припинити ділитися місцезнаходженням", + "shareMyLocation": "Поділитися місцезнаходженням", + "stopRecording": "Зупинити запис", + "recordVoice": "Записати голосове повідомлення", + "moreActions": "Інші дії", + "noMessagesInFilter": "У цьому фільтрі немає повідомлень", + "noMatchesInFilter": "У цьому фільтрі немає збігів", + "syncFailed": "Помилка синхронізації: {error}", + "invalidLink": "Недійсне посилання", + "cannotOpenLink": "Не вдається відкрити посилання", + "failedToOpenLink": "Не вдалося відкрити посилання", + "failedToResendMessage": "Не вдалося повторно надіслати повідомлення", + "retryFailed": "Повторна спроба не вдалася: {error}", + "deleteDrawingAndMessage": "Видалити рисунок і повідомлення", + "lastEchoRelay": "Останнє відлуння: ретранслятор", + "lastEchoPath": "Останнє відлуння: шлях", + "lastEchoBytesReport": "Останнє відлуння: звіт про байти", + "noGpsCoordinatesAvailable": "Координати GPS недоступні", + "notASarMarker": "Це не маркер SAR", + "failedToSaveTemplate": "Не вдалося зберегти шаблон: {error}", + "drawingNotFound": "Рисунок не знайдено", + "unknownDrawingType": "Невідомий тип рисунка", + "customMapMarker": "Власний маркер карти", + "customMapPointLabel": "Точка: {point}", + "mapIdLabel": "ID карти: {id}", + "cannotFetchVoice": "Не вдається отримати голосове повідомлення", + "senderContactUnknown": "Контакт відправника невідомий. Спершу синхронізуйте контакти.", + "senderRouteUnknown": "Маршрут відправника невідомий. Спершу синхронізуйте контакти/шлях.", + "messageTooFar": "Повідомлення задалеко ({hops} хопів, макс. {maxHops}).", + "senderRouteNoPathResponse": "Маршрут відправника не відповів на перевірку шляху. Синхронізуйте контакти/шлях і спробуйте ще раз.", + "senderRouteNoRawResponse": "Маршрут відправника не відповів на raw-транспортному шляху.", + "voiceFetchOverHops": "Отримання голосу через {hops} хопів може зайняти час.", + "voiceReceiveCanceled": "Приймання голосового повідомлення скасовано", + "fetchingMissingVoiceFragments": "Отримання відсутніх фрагментів голосу", + "receivingVoice": "Приймання голосу", + "cannotFetchImage": "Не вдається отримати зображення", + "imageFetchOverHops": "Отримання зображення через {hops} хопів може зайняти час.", + "imageAlreadyBeingReceived": "Зображення вже приймається", + "loadImage": "Завантажити зображення", + "imageFetchFailedToSendRequest": "Не вдалося надіслати запит на зображення", + "imageUnavailable": "Зображення зараз недоступне", + "imageFetchTimedOut": "Час очікування зображення минув", + "imageReceiveCanceled": "Приймання зображення скасовано", + "closeImagePreview": "Закрити перегляд зображення", + "recordingReleaseToSend": "Запис... відпустіть, щоб надіслати", + "sendingVoice": "Надсилання голосу...", + "sendLongPressToRecordVoice": "Надіслати (утримуйте для запису голосу)", + "discardRecording": "Відхилити запис", + "hopCount": "{count, plural, one{{count} хоп} few{{count} хопи} many{{count} хопів} other{{count} хопа}}", + "channelsSectionDescription": "Широкомовні канали для mesh-трафіку поблизу", + "roomsSectionDescription": "Спільні простори для постійної координації команди", + "contactsSectionDescription": "Люди та пристрої в прямій досяжності", + "noRecentChatters": "Немає нещодавніх співрозмовників", + "public": "Публічний", + "failedToSendTicTacToeMove": "Не вдалося надіслати хід у хрестики-нулики", + "youWon": "Ви перемогли", + "opponentWon": "Переміг суперник", + "gameDraw": "Нічия", + "yourTurn": "Ваш хід", + "opponentTurn": "Хід суперника", + "pleaseEnterValidCoordinates": "Введіть коректні координати", + "locationNotAvailable": "Місцезнаходження недоступне", + "customCaveMapPoint": "Власна точка карти печери", + "mapPoint": "Точка карти", + "fetchingMissingImageFragments": "Отримання відсутніх фрагментів…", + "loadingImage": "Завантаження…", + "receivingImage": "Приймання…", + "tapToLoad": "Торкніться, щоб завантажити", + "connectDevice": "Підключити пристрій", + "chooseTransportSubtitle": "Виберіть транспорт: Bluetooth, WiFi або послідовний порт", + "ble": "BLE", + "network": "Мережа", + "serial": "Послідовний порт", + "scan": "Сканувати", + "pressScanToSearchForDevices": "Натисніть «Сканувати», щоб знайти пристрої поблизу", + "failedToConnectToDevice": "Не вдалося підключитися до {name}", + "unknownDevice": "Невідомий пристрій", + "showingCachedResultsTapRefresh": "Показано кешовані результати. Натисніть «Оновити», щоб пересканувати.", + "scanningLocalNetworkOnPort": "Сканування локальної мережі на наявність WiFi-пристроїв MeshCore на порту {port}", + "cancelScanAndAddServer": "Скасувати сканування та додати сервер", + "addServer": "Додати сервер", + "scanningProgressIps": "Сканування... {scanned}/{total} IP-адрес", + "cancelAndEnterManually": "Скасувати та ввести вручну", + "noRecentOrDiscoveredServers": "Поки немає нещодавніх або виявлених серверів", + "recentlyUsed": "Нещодавно використані", + "connecting": "Підключення...", + "discoveredOnThisNetwork": "Виявлені в цій мережі", + "serverNoLongerAvailable": "Сервер {host}:{port} більше недоступний. Виконайте сканування ще раз, щоб знайти активні сервери.", + "failedToConnectToHost": "Не вдалося підключитися до {host}:{port}", + "enterValidIpAddress": "Введіть коректну IP-адресу", + "enterValidTcpPort": "Введіть коректний TCP-порт", + "ipAddress": "IP-адреса", + "tcpPort": "TCP-порт", + "customServerPort": "Власний порт сервера", + "chat": "Чат", + "restoringPreviousLink": "Відновлення попереднього підключення", + "noDeviceConnected": "Немає підключеного пристрою", + "sendAdvert": "Надіслати мій контакт", + "advertMode": "Надіслати мій контакт", + "advertModeSubtitle": "Виберіть, як далеко мають поширитися ваші контактні дані.", + "floodAdvertSent": "Контакт надіслано по всій mesh-мережі", + "directAdvertSent": "Контакт надіслано пристроям поблизу" +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 0000000..df7aeba --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,2617 @@ +{ + "@@locale": "zh", + "appTitle": "MeshCore SAR", + "@appTitle": { + "description": "应用程序标题" + }, + "messages": "消息", + "@messages": { + "description": "消息标签页标题" + }, + "contacts": "联系人", + "@contacts": { + "description": "联系人标签页标题" + }, + "map": "地图", + "@map": { + "description": "地图标签页标题" + }, + "settings": "设置", + "@settings": { + "description": "设置屏幕标题" + }, + "connect": "连接", + "@connect": { + "description": "连接按钮标签" + }, + "disconnect": "断开连接", + "@disconnect": { + "description": "断开连接按钮标签" + }, + "noDevicesFound": "未找到设备", + "@noDevicesFound": { + "description": "未找到 BLE 设备时显示的文本" + }, + "scanAgain": "重新扫描", + "@scanAgain": { + "description": "重新开始 BLE 扫描的按钮" + }, + "deviceNotConnected": "设备未连接", + "@deviceNotConnected": { + "description": "设备未连接时的错误消息" + }, + "locationPermissionDenied": "位置权限被拒绝", + "@locationPermissionDenied": { + "description": "位置权限被拒绝时的错误" + }, + "locationPermissionPermanentlyDenied": "位置权限被永久拒绝。请在设置中启用。", + "@locationPermissionPermanentlyDenied": { + "description": "位置权限被永久拒绝时的错误" + }, + "locationPermissionRequired": "GPS 跟踪和团队协调需要位置权限。您可以稍后在设置中启用。", + "@locationPermissionRequired": { + "description": "需要位置权限时显示的消息" + }, + "locationServicesDisabled": "位置服务已禁用。请在设置中启用。", + "@locationServicesDisabled": { + "description": "位置服务禁用时的错误" + }, + "failedToGetGpsLocation": "获取 GPS 位置失败", + "@failedToGetGpsLocation": { + "description": "无法获取 GPS 位置时的错误" + }, + "failedToAdvertise": "广播失败: {error}", + "@failedToAdvertise": { + "description": "广播失败的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cancelReconnection": "取消重新连接", + "@cancelReconnection": { + "description": "取消重新连接按钮的工具提示" + }, + "general": "通用", + "@general": { + "description": "通用设置区域标题" + }, + "theme": "主题", + "@theme": { + "description": "主题设置标签" + }, + "chooseTheme": "选择主题", + "@chooseTheme": { + "description": "主题选择对话框标题" + }, + "light": "亮色", + "@light": { + "description": "亮色主题选项" + }, + "dark": "暗色", + "@dark": { + "description": "暗色主题选项" + }, + "blueLightTheme": "蓝色亮色主题", + "@blueLightTheme": { + "description": "蓝色亮色主题的描述" + }, + "blueDarkTheme": "蓝色暗色主题", + "@blueDarkTheme": { + "description": "蓝色暗色主题的描述" + }, + "sarRed": "搜救红", + "@sarRed": { + "description": "搜救红主题选项" + }, + "alertEmergencyMode": "警报/紧急模式", + "@alertEmergencyMode": { + "description": "搜救红主题的描述" + }, + "sarGreen": "搜救绿", + "@sarGreen": { + "description": "搜救绿主题选项" + }, + "safeAllClearMode": "安全/解除警报模式", + "@safeAllClearMode": { + "description": "搜救绿主题的描述" + }, + "autoSystem": "自动 (跟随系统)", + "@autoSystem": { + "description": "自动/跟随系统主题选项" + }, + "followSystemTheme": "跟随系统主题", + "@followSystemTheme": { + "description": "跟随系统主题的描述" + }, + "showRxTxIndicators": "显示 RX/TX 指示器", + "@showRxTxIndicators": { + "description": "显示 RX/TX 指示器的设置" + }, + "disableMap": "禁用地图", + "@disableMap": { + "description": "禁用地图标签页的设置" + }, + "language": "语言", + "@language": { + "description": "语言设置标签" + }, + "chooseLanguage": "选择语言", + "@chooseLanguage": { + "description": "语言选择对话框标题" + }, + "save": "保存", + "@save": { + "description": "保存按钮标签" + }, + "cancel": "取消", + "@cancel": { + "description": "取消按钮标签" + }, + "close": "关闭", + "@close": { + "description": "关闭按钮标签" + }, + "about": "关于", + "@about": { + "description": "关于区域标题" + }, + "appVersion": "应用版本", + "@appVersion": { + "description": "应用版本标签" + }, + "aboutMeshCoreSar": "关于 MeshCore 搜救助手", + "@aboutMeshCoreSar": { + "description": "关于对话框标题" + }, + "aboutDescription": "专为应急响应团队设计的搜救应用程序。功能包括:\n\n• 用于设备间通信的 BLE Mesh 网络\n• 带有多个图层选项的离线地图\n• 实时团队成员跟踪\n• 搜救战术标记(找到的人员、火灾、集结区)\n• 联系人管理和消息收发\n• 带有指南针方向的 GPS 跟踪\n• 用于离线使用的地图瓦片缓存", + "@aboutDescription": { + "description": "关于对话框的描述" + }, + "technologiesUsed": "使用的技术:", + "@technologiesUsed": { + "description": "使用的技术区域标题" + }, + "technologiesList": "• 用于跨平台开发的 Flutter\n• 用于 Mesh 网络的 BLE(低功耗蓝牙)\n• 用于地图的 OpenStreetMap\n• 用于状态管理的 Provider\n• 用于本地存储的 SharedPreferences", + "@technologiesList": { + "description": "使用的技术列表" + }, + "moreInfo": "更多信息", + "@moreInfo": { + "description": "更多信息按钮标签" + }, + "packageName": "包名", + "@packageName": { + "description": "包名标签" + }, + "sampleData": "示例数据", + "@sampleData": { + "description": "示例数据区域标题" + }, + "sampleDataDescription": "加载或清除用于测试的示例联系人、频道消息和搜救标记", + "@sampleDataDescription": { + "description": "示例数据区域的描述" + }, + "loadSampleData": "加载示例数据", + "@loadSampleData": { + "description": "加载示例数据按钮" + }, + "clearAllData": "清除所有数据", + "@clearAllData": { + "description": "清除所有数据按钮" + }, + "clearAllDataConfirmTitle": "清除所有数据", + "@clearAllDataConfirmTitle": { + "description": "清除数据确认对话框标题" + }, + "clearAllDataConfirmMessage": "这将清除所有联系人和搜救标记。您确定吗?", + "@clearAllDataConfirmMessage": { + "description": "清除数据确认消息" + }, + "clear": "清除", + "@clear": { + "description": "清除按钮标签" + }, + "loadedSampleData": "已加载 {teamCount} 个团队成员,{channelCount} 个频道,{sarCount} 个搜救标记,{messageCount} 条消息", + "@loadedSampleData": { + "description": "加载示例数据后的成功消息", + "placeholders": { + "teamCount": { + "type": "int" + }, + "channelCount": { + "type": "int" + }, + "sarCount": { + "type": "int" + }, + "messageCount": { + "type": "int" + } + } + }, + "failedToLoadSampleData": "加载示例数据失败:{error}", + "@failedToLoadSampleData": { + "description": "示例数据加载失败时的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "allDataCleared": "所有数据已清除", + "@allDataCleared": { + "description": "清除所有数据后的成功消息" + }, + "failedToStartBackgroundTracking": "启动后台跟踪失败。请检查权限和 BLE 连接。", + "@failedToStartBackgroundTracking": { + "description": "后台跟踪启动失败时的错误消息" + }, + "locationBroadcast": "位置广播:{latitude}, {longitude}", + "@locationBroadcast": { + "description": "位置广播的成功消息", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "defaultPinInfo": "无屏幕设备的默认 PIN 是 123456。配对有问题?请在系统设置中忘记该蓝牙设备。", + "@defaultPinInfo": { + "description": "关于默认配对 PIN 的信息" + }, + "noMessagesYet": "暂无消息", + "@noMessagesYet": { + "description": "没有消息时的空状态消息" + }, + "pullDownToSync": "下拉同步消息", + "@pullDownToSync": { + "description": "下拉刷新消息的提示" + }, + "deleteContact": "删除联系人", + "@deleteContact": { + "description": "删除联系人操作标签" + }, + "delete": "删除", + "@delete": { + "description": "删除按钮标签" + }, + "viewOnMap": "在地图上查看", + "@viewOnMap": { + "description": "在地图上查看联系人位置的操作" + }, + "refresh": "刷新", + "@refresh": { + "description": "刷新按钮标签" + }, + "resetPath": "重置路径 (重新路由)", + "@resetPath": { + "description": "重置联系人路径以重新路由的操作" + }, + "copiedToClipboard": "{label} 已复制到剪贴板", + "@copiedToClipboard": { + "description": "将值复制到剪贴板后的成功消息", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pleaseEnterPassword": "请输入密码", + "@pleaseEnterPassword": { + "description": "密码字段为空的验证消息" + }, + "failedToSyncContacts": "同步联系人失败:{error}", + "@failedToSyncContacts": { + "description": "联系人同步失败时的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "loggedInSuccessfully": "登录成功!正在等待房间消息...", + "@loggedInSuccessfully": { + "description": "成功登录房间后的成功消息" + }, + "loginFailed": "登录失败 - 密码错误", + "@loginFailed": { + "description": "房间登录失败时的错误消息" + }, + "loggingIn": "正在登录到 {roomName}...", + "@loggingIn": { + "description": "房间登录过程中的状态消息", + "placeholders": { + "roomName": { + "type": "String" + } + } + }, + "failedToSendLogin": "发送登录命令失败:{error}", + "@failedToSendLogin": { + "description": "登录命令发送失败时的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "lowLocationAccuracy": "位置精度低", + "@lowLocationAccuracy": { + "description": "GPS 精度低的警告标题" + }, + "continue_": "继续", + "@continue_": { + "description": "继续按钮标签" + }, + "sendSarMarker": "发送搜救标记", + "@sendSarMarker": { + "description": "发送搜救标记的操作" + }, + "deleteDrawing": "删除绘图", + "@deleteDrawing": { + "description": "删除地图绘制的操作" + }, + "drawingTools": "绘图工具", + "@drawingTools": { + "description": "绘图工具区域或菜单标题" + }, + "drawLine": "绘制线条", + "@drawLine": { + "description": "地图绘制模式:线条" + }, + "drawLineDesc": "在地图上绘制手绘线条", + "@drawLineDesc": { + "description": "线条绘制模式的描述" + }, + "drawRectangle": "绘制矩形", + "@drawRectangle": { + "description": "地图绘制模式:矩形" + }, + "drawRectangleDesc": "在地图上绘制矩形区域", + "@drawRectangleDesc": { + "description": "矩形绘制模式的描述" + }, + "measureDistance": "测量距离", + "@measureDistance": { + "description": "地图绘制模式:测量距离" + }, + "measureDistanceDesc": "长按两个点进行测量", + "@measureDistanceDesc": { + "description": "距离测量模式的描述" + }, + "clearMeasurement": "清除测量", + "@clearMeasurement": { + "description": "清除测量的工具提示" + }, + "distanceLabel": "距离:{distance}", + "@distanceLabel": { + "description": "显示测量距离的标签", + "placeholders": { + "distance": { + "type": "String" + } + } + }, + "longPressForSecondPoint": "长按以选择第二个点", + "@longPressForSecondPoint": { + "description": "设置第一个测量点后的提示" + }, + "longPressToStartMeasurement": "长按以设置第一个点", + "@longPressToStartMeasurement": { + "description": "开始测量的提示" + }, + "longPressToStartNewMeasurement": "长按以开始新的测量", + "@longPressToStartNewMeasurement": { + "description": "完成后重新开始测量的提示" + }, + "shareDrawings": "共享绘图", + "@shareDrawings": { + "description": "将绘图共享到网络的操作" + }, + "clearAllDrawings": "清除所有绘图", + "@clearAllDrawings": { + "description": "清除所有本地绘制的操作" + }, + "completeLine": "完成线条", + "@completeLine": { + "description": "完成绘制线条的工具提示" + }, + "broadcastDrawingsToTeam": "向团队广播 {count} 个绘图", + "@broadcastDrawingsToTeam": { + "description": "显示将广播多少个绘图的副标题", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "removeAllDrawings": "移除所有 {count} 个绘图", + "@removeAllDrawings": { + "description": "清除所有绘图操作的副标题", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "deleteAllDrawingsConfirm": "从地图上删除所有 {count} 个绘图吗?", + "@deleteAllDrawingsConfirm": { + "description": "删除所有绘图的确认对话框消息", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "drawing": "绘图", + "@drawing": { + "description": "通用绘图标签" + }, + "shareDrawingsCount": "共享 {count} 个绘图", + "@shareDrawingsCount": { + "description": "共享绘图对话框的标题", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } + }, + "showReceivedDrawings": "显示接收到的绘图", + "@showReceivedDrawings": { + "description": "显示/隐藏来自其他团队成员的绘图的开关" + }, + "showingAllDrawings": "显示所有绘图", + "@showingAllDrawings": { + "description": "当接收到的绘图可见时的副标题" + }, + "showingOnlyYourDrawings": "仅显示您自己的绘图", + "@showingOnlyYourDrawings": { + "description": "当接收到的绘图隐藏时的副标题" + }, + "showSarMarkers": "显示搜救标记", + "@showSarMarkers": { + "description": "在地图上显示/隐藏搜救标记的开关" + }, + "showingSarMarkers": "显示搜救标记", + "@showingSarMarkers": { + "description": "当搜救标记可见时的副标题" + }, + "hidingSarMarkers": "隐藏搜救标记", + "@hidingSarMarkers": { + "description": "当搜救标记隐藏时的副标题" + }, + "clearAll": "全部清除", + "@clearAll": { + "description": "全部清除按钮标签" + }, + "publicChannel": "公共频道", + "@publicChannel": { + "description": "用于共享的公共频道选项" + }, + "broadcastToAll": "广播给所有附近节点(临时)", + "@broadcastToAll": { + "description": "公共频道广播的描述" + }, + "storedPermanently": "永久存储在房间中", + "@storedPermanently": { + "description": "房间存储持久性的描述" + }, + "notConnectedToDevice": "未连接到设备", + "@notConnectedToDevice": { + "description": "直连消息发送时设备未连接的错误消息" + }, + "typeYourMessage": "输入您的消息...", + "@typeYourMessage": { + "description": "消息输入字段的占位符文本" + }, + "quickLocationMarker": "快速位置标记", + "@quickLocationMarker": { + "description": "搜救标记面板标题的副标题" + }, + "markerType": "标记类型", + "@markerType": { + "description": "标记类型选择区域的标签" + }, + "sendTo": "发送至", + "@sendTo": { + "description": "目的地选择区域的标签" + }, + "noDestinationsAvailable": "没有可用的目的地。", + "@noDestinationsAvailable": { + "description": "没有房间或频道时的警告" + }, + "selectDestination": "选择目的地...", + "@selectDestination": { + "description": "目的地下拉菜单的占位符" + }, + "ephemeralBroadcastInfo": "临时:仅通过无线广播。不存储 - 节点必须在线。", + "@ephemeralBroadcastInfo": { + "description": "关于临时频道广播的信息" + }, + "persistentRoomInfo": "持久:不可变地存储在房间中。自动同步并离线保留。", + "@persistentRoomInfo": { + "description": "关于持久房间存储的信息" + }, + "location": "位置", + "@location": { + "description": "位置区域的标签" + }, + "fromMap": "来自地图", + "@fromMap": { + "description": "显示位置来自地图点击的徽章" + }, + "gettingLocation": "正在获取位置...", + "@gettingLocation": { + "description": "获取 GPS 位置时的加载消息" + }, + "locationError": "位置错误", + "@locationError": { + "description": "位置错误消息的标题" + }, + "retry": "重试", + "@retry": { + "description": "重试按钮标签" + }, + "refreshLocation": "刷新位置", + "@refreshLocation": { + "description": "刷新位置按钮的工具提示" + }, + "accuracyMeters": "精度:±{accuracy}米", + "@accuracyMeters": { + "description": "以米为单位的 GPS 精度显示", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "notesOptional": "备注(可选)", + "@notesOptional": { + "description": "可选备注字段的标签" + }, + "addAdditionalInformation": "添加附加信息...", + "@addAdditionalInformation": { + "description": "备注字段的占位符" + }, + "lowAccuracyWarning": "位置精度为 ±{accuracy}米。这可能不足以用于搜救行动。\n\n仍然继续吗?", + "@lowAccuracyWarning": { + "description": "GPS 精度低的警告对话框内容", + "placeholders": { + "accuracy": { + "type": "int" + } + } + }, + "loginToRoom": "登录到房间", + "@loginToRoom": { + "description": "房间登录对话框的标题" + }, + "enterPasswordInfo": "输入访问此房间的密码。密码将被保存以供将来使用。", + "@enterPasswordInfo": { + "description": "关于房间密码的信息" + }, + "password": "密码", + "@password": { + "description": "密码字段标签" + }, + "enterRoomPassword": "输入房间密码", + "@enterRoomPassword": { + "description": "密码字段提示" + }, + "loggingInDots": "正在登录...", + "@loggingInDots": { + "description": "登录过程中的按钮文本" + }, + "login": "登录", + "@login": { + "description": "登录按钮标签" + }, + "failedToAddRoom": "无法将房间添加到设备:{error}\n\n该房间可能尚未广播。\n请等待房间广播。", + "@failedToAddRoom": { + "description": "添加房间失败时的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "direct": "直连", + "@direct": { + "description": "直连路由指示器" + }, + "flood": "泛洪", + "@flood": { + "description": "泛洪路由指示器" + }, + "loggedIn": "已登录", + "@loggedIn": { + "description": "已登录状态徽章" + }, + "noGpsData": "无 GPS 数据", + "@noGpsData": { + "description": "GPS 数据不可用时的消息" + }, + "distance": "距离", + "@distance": { + "description": "距离标签" + }, + "directPingTimeout": "直连 Ping 超时 - 正在使用泛洪重试 {name}...", + "@directPingTimeout": { + "description": "直连 Ping 超时时的警告", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pingFailed": "Ping {name} 失败 - 未收到响应", + "@pingFailed": { + "description": "Ping 失败时的错误消息", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "deleteContactConfirmation": "您确定要删除“{name}”吗?\n\n这将从应用程序和配套的无线电设备中移除该联系人。", + "@deleteContactConfirmation": { + "description": "删除联系人的确认消息", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "failedToRemoveContact": "移除联系人失败:{error}", + "@failedToRemoveContact": { + "description": "联系人移除失败时的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "type": "类型", + "@type": { + "description": "联系人类型标签" + }, + "publicKey": "公钥", + "@publicKey": { + "description": "公钥标签" + }, + "lastSeen": "最后在线", + "@lastSeen": { + "description": "最后在线标签" + }, + "yes": "是", + "@yes": { + "description": "肯定回答" + }, + "no": "否", + "@no": { + "description": "否定回答" + }, + "permissions": "权限", + "@permissions": { + "description": "权限标签" + }, + "telemetry": "遥测", + "@telemetry": { + "description": "遥测区域标题" + }, + "voltage": "电压", + "@voltage": { + "description": "电压标签" + }, + "battery": "电池", + "@battery": { + "description": "电池标签" + }, + "temperature": "温度", + "@temperature": { + "description": "温度标签" + }, + "humidity": "湿度", + "@humidity": { + "description": "湿度标签" + }, + "pressure": "气压", + "@pressure": { + "description": "气压标签" + }, + "gpsTelemetry": "GPS(遥测)", + "@gpsTelemetry": { + "description": "来自遥测的 GPS 标签" + }, + "reLoginToRoom": "重新登录到房间", + "@reLoginToRoom": { + "description": "重新登录到房间的按钮" + }, + "heading": "航向", + "@heading": { + "description": "指南针航向标签" + }, + "elevation": "海拔", + "@elevation": { + "description": "海拔高度标签" + }, + "accuracy": "精度", + "@accuracy": { + "description": "GPS 精度标签" + }, + "bearing": "方位角", + "@bearing": { + "description": "指南针中的方位角标签" + }, + "direction": "方向", + "@direction": { + "description": "指南针中的方向标签" + }, + "filterMarkers": "筛选标记", + "@filterMarkers": { + "description": "筛选标记对话框的标题" + }, + "filterMarkersTooltip": "筛选标记", + "@filterMarkersTooltip": { + "description": "筛选按钮的工具提示" + }, + "contactsFilter": "联系人", + "@contactsFilter": { + "description": "联系人筛选选项" + }, + "repeatersFilter": "转发节点", + "@repeatersFilter": { + "description": "转发节点筛选选项" + }, + "sarMarkers": "搜救标记", + "@sarMarkers": { + "description": "搜救标记区域标题" + }, + "foundPerson": "找到的人员", + "@foundPerson": { + "description": "找到的人员搜救标记类型" + }, + "fire": "火灾", + "@fire": { + "description": "火灾搜救标记类型" + }, + "stagingArea": "集结区", + "@stagingArea": { + "description": "集结区搜救标记类型" + }, + "showAll": "显示全部", + "@showAll": { + "description": "显示所有筛选的按钮" + }, + "locationUnavailable": "位置不可用", + "@locationUnavailable": { + "description": "GPS 位置不可用时的消息" + }, + "ahead": "前方", + "@ahead": { + "description": "相对方位方向 - 前方" + }, + "degreesRight": "向右 {degrees}°", + "@degreesRight": { + "description": "相对方位方向 - 右侧", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "degreesLeft": "向左 {degrees}°", + "@degreesLeft": { + "description": "相对方位方向 - 左侧", + "placeholders": { + "degrees": { + "type": "int" + } + } + }, + "latLonFormat": "纬度:{latitude} 经度:{longitude}", + "@latLonFormat": { + "description": "纬度和经度显示格式", + "placeholders": { + "latitude": { + "type": "String" + }, + "longitude": { + "type": "String" + } + } + }, + "noContactsYet": "暂无联系人", + "@noContactsYet": { + "description": "没有联系人时的空状态消息" + }, + "connectToDeviceToLoadContacts": "连接设备以加载联系人", + "@connectToDeviceToLoadContacts": { + "description": "连接设备加载联系人的提示" + }, + "teamMembers": "团队成员", + "@teamMembers": { + "description": "团队成员区域标题(聊天联系人)" + }, + "repeaters": "转发节点", + "@repeaters": { + "description": "转发节点区域标题" + }, + "rooms": "房间", + "@rooms": { + "description": "房间区域标题" + }, + "channels": "频道", + "@channels": { + "description": "广播频道区域标题" + }, + "selectMapLayer": "选择地图图层", + "@selectMapLayer": { + "description": "地图图层选择对话框的标题" + }, + "openStreetMap": "OpenStreetMap", + "@openStreetMap": { + "description": "OpenStreetMap 图层名称" + }, + "openTopoMap": "OpenTopoMap", + "@openTopoMap": { + "description": "OpenTopoMap 图层名称" + }, + "esriSatellite": "ESRI 卫星图", + "@esriSatellite": { + "description": "ESRI 卫星影像图层名称" + }, + "googleHybrid": "Google 混合图", + "@googleHybrid": { + "description": "Google 混合图层名称(卫星 + 标签)" + }, + "googleRoadmap": "Google 道路图", + "@googleRoadmap": { + "description": "Google 道路图层名称(街道地图)" + }, + "googleTerrain": "Google 地形图", + "@googleTerrain": { + "description": "Google 地形图层名称(地形图)" + }, + "dragToPosition": "拖动以定位", + "@dragToPosition": { + "description": "在地图上拖动图钉时的标签" + }, + "createSarMarker": "创建搜救标记", + "@createSarMarker": { + "description": "从图钉创建搜救标记的标签" + }, + "compass": "指南针", + "@compass": { + "description": "详细指南针对话框中的指南针标题" + }, + "navigationAndContacts": "导航与联系人", + "@navigationAndContacts": { + "description": "指南针对话框的副标题" + }, + "sarAlert": "搜救警报", + "@sarAlert": { + "description": "消息上的搜救警报徽章标签" + }, + "textCopiedToClipboard": "文本已复制到剪贴板", + "@textCopiedToClipboard": { + "description": "文本复制后的成功消息" + }, + "cannotReplySenderMissing": "无法回复:缺少发送者信息", + "@cannotReplySenderMissing": { + "description": "回复时缺少发送者信息的错误" + }, + "cannotReplyContactNotFound": "无法回复:未找到联系人", + "@cannotReplyContactNotFound": { + "description": "回复时未找到联系人的错误" + }, + "copyText": "复制文本", + "@copyText": { + "description": "将消息文本复制到剪贴板的选项" + }, + "saveAsTemplate": "另存为模板", + "@saveAsTemplate": { + "description": "将搜救消息保存为可重用模板的选项" + }, + "templateSaved": "模板保存成功", + "@templateSaved": { + "description": "搜救模板保存后的成功消息" + }, + "templateAlreadyExists": "具有此表情符号的模板已存在", + "@templateAlreadyExists": { + "description": "尝试保存重复模板时的错误消息" + }, + "deleteMessage": "删除消息", + "@deleteMessage": { + "description": "删除消息的对话框标题" + }, + "deleteMessageConfirmation": "您确定要删除此消息吗?", + "@deleteMessageConfirmation": { + "description": "消息删除的确认文本" + }, + "shareLocation": "共享位置", + "@shareLocation": { + "description": "共享搜救标记位置的选项" + }, + "shareLocationText": "{markerInfo}\n\n坐标:{lat}, {lon}\n\n谷歌地图:{url}", + "@shareLocationText": { + "description": "用于共享搜救标记位置的格式化文本", + "placeholders": { + "markerInfo": { + "type": "String" + }, + "lat": { + "type": "String" + }, + "lon": { + "type": "String" + }, + "url": { + "type": "String" + } + } + }, + "sarLocationShare": "搜救位置", + "@sarLocationShare": { + "description": "共享搜救标记位置时的主题行" + }, + "justNow": "刚刚", + "@justNow": { + "description": "非常近期活动的时间指示器" + }, + "minutesAgo": "{minutes}分钟前", + "@minutesAgo": { + "description": "分钟前的时间指示器", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "hoursAgo": "{hours}小时前", + "@hoursAgo": { + "description": "小时前的时间指示器", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "daysAgo": "{days}天前", + "@daysAgo": { + "description": "天前的时间指示器", + "placeholders": { + "days": { + "type": "int" + } + } + }, + "secondsAgo": "{seconds}秒前", + "@secondsAgo": { + "description": "秒前的时间指示器", + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "sending": "发送中...", + "@sending": { + "description": "投递状态:发送中" + }, + "sent": "已发送", + "@sent": { + "description": "投递状态:已发送" + }, + "delivered": "已送达", + "@delivered": { + "description": "投递状态:已送达" + }, + "deliveredWithTime": "已送达({time}毫秒)", + "@deliveredWithTime": { + "description": "带有往返时间的投递状态", + "placeholders": { + "time": { + "type": "int" + } + } + }, + "failed": "失败", + "@failed": { + "description": "投递状态:失败" + }, + "broadcast": "广播", + "@broadcast": { + "description": "频道消息的投递状态(尚无回显)" + }, + "deliveredToContacts": "已送达 {delivered}/{total} 个联系人", + "@deliveredToContacts": { + "description": "分组消息投递计数", + "placeholders": { + "delivered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "allDelivered": "全部送达", + "@allDelivered": { + "description": "当所有收件人都收到消息时的状态" + }, + "recipientDetails": "收件人详情", + "@recipientDetails": { + "description": "可展开收件人列表的标题" + }, + "pending": "等待中", + "@pending": { + "description": "投递状态:等待中" + }, + "sarMarkerFoundPerson": "找到的人员", + "@sarMarkerFoundPerson": { + "description": "搜救标记类型:找到的人员" + }, + "sarMarkerFire": "火灾位置", + "@sarMarkerFire": { + "description": "搜救标记类型:火灾" + }, + "sarMarkerStagingArea": "集结区", + "@sarMarkerStagingArea": { + "description": "搜救标记类型:集结区" + }, + "sarMarkerObject": "发现的物品", + "@sarMarkerObject": { + "description": "搜救标记类型:物品" + }, + "from": "来自", + "@from": { + "description": "通知中的发件人标签" + }, + "coordinates": "坐标", + "@coordinates": { + "description": "坐标标签" + }, + "tapToViewOnMap": "点击在地图上查看", + "@tapToViewOnMap": { + "description": "通知操作文本" + }, + "radioSettings": "无线电设置", + "@radioSettings": { + "description": "无线电设置部分标题" + }, + "frequencyMHz": "频率 (MHz)", + "@frequencyMHz": { + "description": "无线电频率字段标签" + }, + "bandwidth": "带宽", + "@bandwidth": { + "description": "带宽下拉菜单标签" + }, + "spreadingFactor": "扩频因子", + "@spreadingFactor": { + "description": "扩频因子下拉菜单标签" + }, + "codingRate": "编码率", + "@codingRate": { + "description": "编码率下拉菜单标签" + }, + "txPowerDbm": "发射功率 (dBm)", + "@txPowerDbm": { + "description": "发射功率字段标签" + }, + "maxPowerDbm": "最大:{power} dBm", + "@maxPowerDbm": { + "description": "显示最大发射功率的帮助文本", + "placeholders": { + "power": { + "type": "int" + } + } + }, + "you": "您", + "@you": { + "description": "消息气泡中当前用户的标签" + }, + "exportFailed": "导出失败:{error}", + "@exportFailed": { + "description": "导出失败时的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "importFailed": "导入失败:{error}", + "@importFailed": { + "description": "导入失败时的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "unknown": "未知", + "@unknown": { + "description": "未知值标签" + }, + "onlineLayers": "在线图层", + "@onlineLayers": { + "description": "在线地图图层区域标题" + }, + "locationTrail": "位置轨迹", + "@locationTrail": { + "description": "位置轨迹标题" + }, + "showTrailOnMap": "在地图上显示轨迹", + "@showTrailOnMap": { + "description": "在地图上显示/隐藏轨迹的开关" + }, + "trailVisible": "轨迹在地图上可见", + "@trailVisible": { + "description": "轨迹可见性状态 - 可见" + }, + "trailHiddenRecording": "轨迹已隐藏(仍在记录)", + "@trailHiddenRecording": { + "description": "轨迹可见性状态 - 隐藏但仍在记录" + }, + "duration": "持续时间", + "@duration": { + "description": "持续时间标签" + }, + "points": "点数", + "@points": { + "description": "轨迹点计数标签" + }, + "clearTrail": "清除轨迹", + "@clearTrail": { + "description": "清除位置轨迹的按钮" + }, + "clearTrailQuestion": "清除轨迹?", + "@clearTrailQuestion": { + "description": "确认对话框标题" + }, + "clearTrailConfirmation": "您确定要清除当前的位置轨迹吗?此操作无法撤销。", + "@clearTrailConfirmation": { + "description": "确认对话框消息" + }, + "noTrailRecorded": "尚未记录轨迹", + "@noTrailRecorded": { + "description": "不存在轨迹时的消息" + }, + "startTrackingToRecord": "开始位置跟踪以记录您的轨迹", + "@startTrackingToRecord": { + "description": "开始轨迹记录的提示" + }, + "trailControls": "轨迹控制", + "@trailControls": { + "description": "轨迹控制工具提示" + }, + "model": "型号", + "@model": { + "description": "设备型号标签" + }, + "version": "版本", + "@version": { + "description": "版本标签" + }, + "maxContacts": "最大联系人数量", + "@maxContacts": { + "description": "最大联系人容量标签" + }, + "maxChannels": "最大频道数量", + "@maxChannels": { + "description": "最大频道容量标签" + }, + "publicInfo": "公共信息", + "@publicInfo": { + "description": "公共信息区域标题" + }, + "useCurrentLocation": "使用当前位置", + "@useCurrentLocation": { + "description": "使用当前位置按钮的工具提示" + }, + "noneUnknown": "无/未知", + "@noneUnknown": { + "description": "设备类型:无或未知" + }, + "chatNode": "聊天节点", + "@chatNode": { + "description": "设备类型:聊天节点" + }, + "repeater": "转发节点", + "@repeater": { + "description": "设备类型:转发节点" + }, + "roomChannel": "房间/频道", + "@roomChannel": { + "description": "设备类型:房间或频道" + }, + "typeNumber": "类型 {number}", + "@typeNumber": { + "description": "带有编号的通用设备类型", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "failedToSave": "保存失败:{error}", + "@failedToSave": { + "description": "保存失败的通用错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "failedToGetLocation": "获取位置失败:{error}", + "@failedToGetLocation": { + "description": "获取位置失败时的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "sarTemplates": "搜救模板", + "@sarTemplates": { + "description": "搜救模板菜单标题" + }, + "addTemplate": "添加模板", + "@addTemplate": { + "description": "添加新搜救模板的按钮" + }, + "editTemplate": "编辑模板", + "@editTemplate": { + "description": "编辑模板对话框的标题" + }, + "deleteTemplate": "删除模板", + "@deleteTemplate": { + "description": "删除模板的操作" + }, + "templateName": "模板名称", + "@templateName": { + "description": "模板名称字段标签" + }, + "templateNameHint": "例如 找到的人员", + "@templateNameHint": { + "description": "模板名称的提示文本" + }, + "templateEmoji": "表情符号", + "@templateEmoji": { + "description": "模板表情符号字段标签" + }, + "emojiRequired": "需要表情符号", + "@emojiRequired": { + "description": "表情符号字段为空时的验证错误" + }, + "nameRequired": "需要名称", + "@nameRequired": { + "description": "名称字段为空时的验证错误" + }, + "templateDescription": "描述(可选)", + "@templateDescription": { + "description": "模板描述字段标签" + }, + "templateDescriptionHint": "添加上下文信息...", + "@templateDescriptionHint": { + "description": "模板描述的提示文本" + }, + "templateColor": "颜色", + "@templateColor": { + "description": "模板颜色选择器标签" + }, + "previewFormat": "预览(搜救消息格式)", + "@previewFormat": { + "description": "格式预览标签" + }, + "importFromClipboard": "导入", + "@importFromClipboard": { + "description": "从剪贴板导入模板的按钮" + }, + "exportToClipboard": "导出", + "@exportToClipboard": { + "description": "将模板导出到剪贴板的按钮" + }, + "deleteTemplateConfirmation": "删除模板“{name}”?", + "@deleteTemplateConfirmation": { + "description": "模板删除的确认消息", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "templateAdded": "模板已添加", + "@templateAdded": { + "description": "模板添加后的成功消息" + }, + "templateUpdated": "模板已更新", + "@templateUpdated": { + "description": "模板更新后的成功消息" + }, + "templateDeleted": "模板已删除", + "@templateDeleted": { + "description": "模板删除后的成功消息" + }, + "templatesImported": "已导入 {count} 个模板", + "@templatesImported": { + "description": "导入模板后的成功消息", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "templatesExported": "已导出 {count} 个模板到剪贴板", + "@templatesExported": { + "description": "导出模板后的成功消息", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "resetToDefaults": "恢复默认", + "@resetToDefaults": { + "description": "将模板重置为默认值的操作" + }, + "resetToDefaultsConfirmation": "这将删除所有自定义模板并恢复 4 个默认模板。是否继续?", + "@resetToDefaultsConfirmation": { + "description": "恢复默认的确认消息" + }, + "reset": "重置", + "@reset": { + "description": "重置按钮标签" + }, + "resetComplete": "模板已重置为默认值", + "@resetComplete": { + "description": "重置后的成功消息" + }, + "noTemplates": "没有可用的模板", + "@noTemplates": { + "description": "不存在模板时的消息" + }, + "tapAddToCreate": "点击 + 创建您的第一个模板", + "@tapAddToCreate": { + "description": "不存在模板时的帮助文本" + }, + "ok": "确定", + "@ok": { + "description": "确定按钮标签" + }, + "locationPermission": "位置权限", + "@locationPermission": { + "description": "位置权限标签" + }, + "checking": "正在检查...", + "@checking": { + "description": "加载状态指示器" + }, + "locationPermissionGrantedAlways": "已授予(始终)", + "@locationPermissionGrantedAlways": { + "description": "位置权限状态:始终授予" + }, + "locationPermissionGrantedWhileInUse": "已授予(使用期间)", + "@locationPermissionGrantedWhileInUse": { + "description": "位置权限状态:使用期间授予" + }, + "locationPermissionDeniedTapToRequest": "已拒绝 - 点击请求", + "@locationPermissionDeniedTapToRequest": { + "description": "位置权限状态:已拒绝,用户可以请求" + }, + "locationPermissionPermanentlyDeniedOpenSettings": "永久拒绝 - 打开设置", + "@locationPermissionPermanentlyDeniedOpenSettings": { + "description": "位置权限状态:永久拒绝" + }, + "locationPermissionDialogContent": "位置权限被永久拒绝。请在设备设置中启用它,以便使用 GPS 跟踪和位置共享功能。", + "@locationPermissionDialogContent": { + "description": "永久拒绝时位置权限对话框的内容" + }, + "openSettings": "打开设置", + "@openSettings": { + "description": "打开设备设置的按钮" + }, + "locationPermissionGranted": "位置权限已授予!", + "@locationPermissionGranted": { + "description": "位置权限授予后的成功消息" + }, + "locationPermissionRequiredForGps": "GPS 跟踪和位置共享需要位置权限。", + "@locationPermissionRequiredForGps": { + "description": "关于位置权限要求的信息消息" + }, + "locationPermissionAlreadyGranted": "位置权限已被授予。", + "@locationPermissionAlreadyGranted": { + "description": "权限已授予时的信息消息" + }, + "sarNavyBlue": "搜救海军蓝", + "@sarNavyBlue": { + "description": "搜救海军蓝主题名称" + }, + "sarNavyBlueDescription": "专业/操作模式", + "@sarNavyBlueDescription": { + "description": "搜救海军蓝主题的描述" + }, + "selectRecipient": "选择收件人", + "@selectRecipient": { + "description": "收件人选择面板的标题" + }, + "broadcastToAllNearby": "广播给所有附近设备", + "@broadcastToAllNearby": { + "description": "公共频道选项的副标题" + }, + "searchRecipients": "搜索收件人...", + "@searchRecipients": { + "description": "收件人搜索字段的占位符文本" + }, + "noContactsFound": "未找到联系人", + "@noContactsFound": { + "description": "没有联系人匹配搜索时的消息" + }, + "noRoomsFound": "未找到房间", + "@noRoomsFound": { + "description": "没有房间匹配搜索时的消息" + }, + "noRecipientsAvailable": "没有可用的收件人", + "@noRecipientsAvailable": { + "description": "没有收件人(联系人、房间或频道)存在时的消息" + }, + "noChannelsFound": "未找到频道", + "@noChannelsFound": { + "description": "没有频道匹配搜索时的消息" + }, + "newMessage": "新消息", + "@newMessage": { + "description": "新消息的通知标题" + }, + "channel": "频道", + "@channel": { + "description": "通知中的频道标签" + }, + "samplePoliceLead": "警队负责人", + "@samplePoliceLead": { + "description": "示例团队成员名称" + }, + "sampleDroneOperator": "无人机操作员", + "@sampleDroneOperator": { + "description": "示例团队成员名称" + }, + "sampleFirefighterAlpha": "消防员", + "@sampleFirefighterAlpha": { + "description": "示例团队成员名称" + }, + "sampleMedicCharlie": "医护人员", + "@sampleMedicCharlie": { + "description": "示例团队成员名称" + }, + "sampleCommandDelta": "指挥部", + "@sampleCommandDelta": { + "description": "示例团队成员名称" + }, + "sampleFireEngine": "消防车", + "@sampleFireEngine": { + "description": "示例团队成员名称" + }, + "sampleAirSupport": "空中支援", + "@sampleAirSupport": { + "description": "示例团队成员名称" + }, + "sampleBaseCoordinator": "基地协调员", + "@sampleBaseCoordinator": { + "description": "示例团队成员名称" + }, + "channelEmergency": "紧急", + "@channelEmergency": { + "description": "紧急频道名称" + }, + "channelCoordination": "协调", + "@channelCoordination": { + "description": "协调频道名称" + }, + "channelUpdates": "更新", + "@channelUpdates": { + "description": "更新频道名称" + }, + "sampleTeamMember": "示例团队成员", + "@sampleTeamMember": { + "description": "示例发件人名称" + }, + "sampleScout": "示例侦察员", + "@sampleScout": { + "description": "示例发件人名称" + }, + "sampleBase": "示例基地", + "@sampleBase": { + "description": "示例发件人名称" + }, + "sampleSearcher": "示例搜索员", + "@sampleSearcher": { + "description": "示例发件人名称" + }, + "sampleObjectBackpack": " 发现的背包 - 蓝色", + "@sampleObjectBackpack": { + "description": "示例物品备注" + }, + "sampleObjectVehicle": " 遗弃车辆 - 请核实车主", + "@sampleObjectVehicle": { + "description": "示例物品备注" + }, + "sampleObjectCamping": " 发现的露营装备", + "@sampleObjectCamping": { + "description": "示例物品备注" + }, + "sampleObjectTrailMarker": " 在小径外发现的路标", + "@sampleObjectTrailMarker": { + "description": "示例物品备注" + }, + "sampleMsgAllTeamsCheckIn": "所有小队签到", + "@sampleMsgAllTeamsCheckIn": { + "description": "示例频道消息" + }, + "sampleMsgWeatherUpdate": "天气更新:晴朗,气温 18°C", + "@sampleMsgWeatherUpdate": { + "description": "示例频道消息" + }, + "sampleMsgBaseCamp": "基地营已在集结区建立", + "@sampleMsgBaseCamp": { + "description": "示例频道消息" + }, + "sampleMsgTeamAlpha": "小队正在前往第 2 区", + "@sampleMsgTeamAlpha": { + "description": "示例频道消息" + }, + "sampleMsgRadioCheck": "无线电检查 - 所有电台回应", + "@sampleMsgRadioCheck": { + "description": "示例频道消息" + }, + "sampleMsgWaterSupply": "3 号检查点有水源", + "@sampleMsgWaterSupply": { + "description": "示例频道消息" + }, + "sampleMsgTeamBravo": "小队报告:第 1 区已排查完毕", + "@sampleMsgTeamBravo": { + "description": "示例频道消息" + }, + "sampleMsgEtaRallyPoint": "预计到达集合点时间:15 分钟", + "@sampleMsgEtaRallyPoint": { + "description": "示例频道消息" + }, + "sampleMsgSupplyDrop": "已确认 14:00 进行物资空投", + "@sampleMsgSupplyDrop": { + "description": "示例频道消息" + }, + "sampleMsgDroneSurvey": "无人机勘察完成 - 无发现", + "@sampleMsgDroneSurvey": { + "description": "示例频道消息" + }, + "sampleMsgTeamCharlie": "小队请求支援", + "@sampleMsgTeamCharlie": { + "description": "示例频道消息" + }, + "sampleMsgRadioDiscipline": "所有单位:保持无线电纪律", + "@sampleMsgRadioDiscipline": { + "description": "示例频道消息" + }, + "sampleMsgUrgentMedical": "紧急:第 4 区需要医疗援助", + "@sampleMsgUrgentMedical": { + "description": "示例紧急消息" + }, + "sampleMsgAdultMale": " 成年男性,意识清醒", + "@sampleMsgAdultMale": { + "description": "示例紧急消息备注" + }, + "sampleMsgFireSpotted": "发现火灾 - 坐标即将发送", + "@sampleMsgFireSpotted": { + "description": "示例紧急消息" + }, + "sampleMsgSpreadingRapidly": " 正在迅速蔓延!", + "@sampleMsgSpreadingRapidly": { + "description": "示例紧急消息备注" + }, + "sampleMsgPriorityHelicopter": "优先级:需要直升机支援", + "@sampleMsgPriorityHelicopter": { + "description": "示例紧急消息" + }, + "sampleMsgMedicalTeamEnRoute": "医疗队正在前往您的位置", + "@sampleMsgMedicalTeamEnRoute": { + "description": "示例紧急消息" + }, + "sampleMsgEvacHelicopter": "救援直升机预计 10 分钟后到达", + "@sampleMsgEvacHelicopter": { + "description": "示例紧急消息" + }, + "sampleMsgEmergencyResolved": "紧急情况已解决 - 解除警报", + "@sampleMsgEmergencyResolved": { + "description": "示例紧急消息" + }, + "sampleMsgEmergencyStagingArea": " 紧急集结区", + "@sampleMsgEmergencyStagingArea": { + "description": "示例紧急消息备注" + }, + "sampleMsgEmergencyServices": "紧急服务部门已接到通知并正在响应", + "@sampleMsgEmergencyServices": { + "description": "示例紧急消息" + }, + "sampleAlphaTeamLead": "小队队长", + "@sampleAlphaTeamLead": { + "description": "示例小队名称" + }, + "sampleBravoScout": "侦察员", + "@sampleBravoScout": { + "description": "示例小队名称" + }, + "sampleCharlieMedic": "医护人员", + "@sampleCharlieMedic": { + "description": "示例小队名称" + }, + "sampleDeltaNavigator": "导航员", + "@sampleDeltaNavigator": { + "description": "示例小队名称" + }, + "sampleEchoSupport": "支援", + "@sampleEchoSupport": { + "description": "示例小队名称" + }, + "sampleBaseCommand": "基地指挥部", + "@sampleBaseCommand": { + "description": "示例小队名称" + }, + "sampleFieldCoordinator": "现场协调员", + "@sampleFieldCoordinator": { + "description": "示例小队名称" + }, + "sampleMedicalTeam": "医疗队", + "@sampleMedicalTeam": { + "description": "示例小队名称" + }, + "mapDrawing": "地图绘制", + "@mapDrawing": { + "description": "地图绘制消息的标签" + }, + "navigateToDrawing": "导航到绘图", + "@navigateToDrawing": { + "description": "导航到地图上绘图的选项" + }, + "copyCoordinates": "复制坐标", + "@copyCoordinates": { + "description": "将坐标复制到剪贴板的选项" + }, + "hideFromMap": "在地图上隐藏", + "@hideFromMap": { + "description": "在地图上隐藏绘图的选项" + }, + "lineDrawing": "线条绘图", + "@lineDrawing": { + "description": "线条类型绘图的标签" + }, + "rectangleDrawing": "矩形绘图", + "@rectangleDrawing": { + "description": "矩形类型绘图的标签" + }, + "manualCoordinates": "手动输入坐标", + "@manualCoordinates": { + "description": "手动坐标输入开关的标签" + }, + "enterCoordinatesManually": "手动输入坐标", + "@enterCoordinatesManually": { + "description": "手动坐标输入选项的描述" + }, + "latitudeLabel": "纬度", + "@latitudeLabel": { + "description": "纬度输入字段的标签" + }, + "longitudeLabel": "经度", + "@longitudeLabel": { + "description": "经度输入字段的标签" + }, + "exampleCoordinates": "例如:46.0569, 14.5058", + "@exampleCoordinates": { + "description": "示例坐标格式提示" + }, + "shareDrawing": "共享绘图", + "@shareDrawing": { + "description": "共享单个绘图对话框的标题" + }, + "shareWithAllNearbyDevices": "与所有附近设备共享", + "@shareWithAllNearbyDevices": { + "description": "公共频道共享选项的副标题" + }, + "shareToRoom": "共享到房间", + "@shareToRoom": { + "description": "房间共享部分的标题" + }, + "sendToPersistentStorage": "发送到持久房间存储", + "@sendToPersistentStorage": { + "description": "房间共享选项的副标题" + }, + "deleteDrawingConfirm": "您确定要删除此绘图吗?", + "@deleteDrawingConfirm": { + "description": "删除单个绘图的确认消息" + }, + "drawingDeleted": "绘图已删除", + "@drawingDeleted": { + "description": "删除绘图后的成功消息" + }, + "yourDrawingsCount": "您的绘图 ({count})", + "@yourDrawingsCount": { + "description": "显示用户绘图数量的标题", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shared": "已共享", + "@shared": { + "description": "已共享绘制的状态标签" + }, + "line": "线条", + "@line": { + "description": "线条绘图类型标签" + }, + "rectangle": "矩形", + "@rectangle": { + "description": "矩形绘图类型标签" + }, + "updateAvailable": "有可用更新", + "@updateAvailable": { + "description": "有新版本可用时更新对话框的标题" + }, + "currentVersion": "当前版本", + "@currentVersion": { + "description": "当前应用版本标签" + }, + "latestVersion": "最新版本", + "@latestVersion": { + "description": "最新可用应用版本标签" + }, + "downloadUpdate": "下载", + "@downloadUpdate": { + "description": "下载应用更新的按钮" + }, + "updateLater": "稍后", + "@updateLater": { + "description": "关闭更新对话框的按钮" + }, + "cadastralParcels": "地籍地块", + "@cadastralParcels": { + "description": "地籍地块 WMS 覆盖图层标签" + }, + "forestRoads": "森林道路", + "@forestRoads": { + "description": "森林道路 WMS 覆盖图层标签" + }, + "wmsOverlays": "WMS 覆盖层", + "@wmsOverlays": { + "description": "图层选择器中 WMS 覆盖图层部分的标题" + }, + "hikingTrails": "徒步小径", + "@hikingTrails": { + "description": "徒步/山地小径 WMS 覆盖图层标签" + }, + "mainRoads": "主要道路", + "@mainRoads": { + "description": "主要道路 WMS 覆盖图层标签" + }, + "houseNumbers": "门牌号", + "@houseNumbers": { + "description": "门牌号 WMS 覆盖图层标签" + }, + "fireHazardZones": "火灾危险区", + "@fireHazardZones": { + "description": "火灾风险区域 WMS 覆盖图层标签" + }, + "historicalFires": "历史火灾", + "@historicalFires": { + "description": "历史森林火灾 WMS 覆盖图层标签" + }, + "firebreaks": "防火隔离带", + "@firebreaks": { + "description": "防火隔离带 WMS 覆盖图层标签" + }, + "krasFireZones": "喀斯特火灾区", + "@krasFireZones": { + "description": "喀斯特火灾区 WMS 覆盖图层标签" + }, + "placeNames": "地名", + "@placeNames": { + "description": "地理地名 WMS 覆盖图层标签" + }, + "municipalityBorders": "市镇边界", + "@municipalityBorders": { + "description": "市镇边界 WMS 覆盖图层标签" + }, + "topographicMap": "地形图 1:25000", + "@topographicMap": { + "description": "DTK25 地形底图图层标签" + }, + "recentMessages": "最近消息", + "@recentMessages": { + "description": "全屏模式下地图上最近消息覆盖层的标题" + }, + "addChannel": "添加频道", + "@addChannel": { + "description": "添加新频道的按钮" + }, + "channelName": "频道名称", + "@channelName": { + "description": "频道名称字段标签" + }, + "channelNameHint": "例如 救援队阿尔法", + "@channelNameHint": { + "description": "频道名称字段的提示" + }, + "channelSecret": "频道密钥", + "@channelSecret": { + "description": "频道密钥字段标签" + }, + "channelSecretHint": "此频道的共享密码", + "@channelSecretHint": { + "description": "频道密钥字段的提示" + }, + "channelSecretHelp": "此密钥必须与需要访问此频道的所有团队成员共享", + "@channelSecretHelp": { + "description": "解释频道密钥的帮助文本" + }, + "channelTypesInfo": "哈希频道 (#团队):密钥根据名称自动生成。名称相同 = 跨设备频道相同。\n\n私有频道:使用显式密钥。只有拥有密钥的人才能加入。", + "@channelTypesInfo": { + "description": "解释哈希频道和私有频道类型的信息横幅" + }, + "hashChannelInfo": "哈希频道:密钥将根据频道名称自动生成。任何使用相同名称的人将加入同一个频道。", + "@hashChannelInfo": { + "description": "哈希频道(# 前缀)的帮助文本" + }, + "channelNameRequired": "需要频道名称", + "@channelNameRequired": { + "description": "频道名称为空时的验证错误" + }, + "channelNameTooLong": "频道名称必须为 31 个字符或更少", + "@channelNameTooLong": { + "description": "频道名称太长的验证错误" + }, + "channelSecretRequired": "需要频道密钥", + "@channelSecretRequired": { + "description": "频道密钥为空时的验证错误" + }, + "channelSecretTooLong": "频道密钥必须为 32 个字符或更少", + "@channelSecretTooLong": { + "description": "频道密钥太长的验证错误" + }, + "invalidAsciiCharacters": "只允许 ASCII 字符", + "@invalidAsciiCharacters": { + "description": "包含非 ASCII 字符时的验证错误" + }, + "channelCreatedSuccessfully": "频道创建成功", + "@channelCreatedSuccessfully": { + "description": "创建频道后的成功消息" + }, + "channelCreationFailed": "创建频道失败:{error}", + "@channelCreationFailed": { + "description": "频道创建失败时的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "deleteChannel": "删除频道", + "@deleteChannel": { + "description": "删除频道按钮/菜单项" + }, + "deleteChannelConfirmation": "您确定要删除频道“{channelName}”吗?此操作无法撤销。", + "@deleteChannelConfirmation": { + "description": "删除频道时的确认对话框", + "placeholders": { + "channelName": { + "type": "String" + } + } + }, + "channelDeletedSuccessfully": "频道删除成功", + "@channelDeletedSuccessfully": { + "description": "删除频道后的成功消息" + }, + "channelDeletionFailed": "删除频道失败:{error}", + "@channelDeletionFailed": { + "description": "频道删除失败时的错误消息", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "createChannel": "创建频道", + "@createChannel": { + "description": "创建频道的按钮文本" + }, + "wizardBack": "返回", + "@wizardBack": { + "description": "向导返回按钮文本" + }, + "wizardSkip": "跳过", + "@wizardSkip": { + "description": "向导跳过按钮文本" + }, + "wizardNext": "下一步", + "@wizardNext": { + "description": "向导下一步按钮文本" + }, + "wizardGetStarted": "开始使用", + "@wizardGetStarted": { + "description": "向导完成引导的最后按钮文本" + }, + "wizardWelcomeTitle": "欢迎使用 MeshCore 搜救助手", + "@wizardWelcomeTitle": { + "description": "欢迎向导第一页标题" + }, + "viewWelcomeTutorial": "查看欢迎教程", + "@viewWelcomeTutorial": { + "description": "设置中重新显示欢迎向导的选项" + }, + "allTeamContacts": "所有团队联系人", + "@allTeamContacts": { + "description": "将搜救标记发送给所有团队联系人的目的地选项" + }, + "directMessagesInfo": "带有确认消息的直连消息。发送给 {count} 名团队成员。", + "@directMessagesInfo": { + "description": "关于发送给所有联系人的信息", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "sarMarkerSentToContacts": "搜救标记已发送给 {count} 个联系人", + "@sarMarkerSentToContacts": { + "description": "将搜救标记发送给所有联系人后的成功消息", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noContactsAvailable": "没有可用的团队联系人", + "@noContactsAvailable": { + "description": "没有可发送的聊天联系人时的消息" + }, + "reply": "回复", + "@reply": {}, + "technicalDetails": "技术细节", + "@technicalDetails": {}, + "messageTechnicalDetails": "消息技术细节", + "@messageTechnicalDetails": {}, + "linkQuality": "链路质量", + "@linkQuality": {}, + "delivery": "投递", + "@delivery": {}, + "status": "状态", + "@status": {}, + "expectedAckTag": "预期的 ACK 标签", + "@expectedAckTag": {}, + "roundTrip": "往返时间", + "@roundTrip": {}, + "retryAttempt": "重试尝试", + "@retryAttempt": {}, + "floodFallback": "泛洪回退", + "@floodFallback": {}, + "identity": "身份", + "@identity": {}, + "messageId": "消息 ID", + "@messageId": {}, + "sender": "发送者", + "@sender": {}, + "senderKey": "发送者密钥", + "@senderKey": {}, + "recipient": "接收者", + "@recipient": {}, + "recipientKey": "接收者密钥", + "@recipientKey": {}, + "voice": "语音", + "@voice": {}, + "voiceId": "语音 ID", + "@voiceId": {}, + "envelope": "信封", + "@envelope": {}, + "sessionProgress": "会话进度", + "@sessionProgress": {}, + "complete": "完成", + "@complete": {}, + "rawDump": "原始数据", + "@rawDump": {}, + "cannotRetryMissingRecipient": "无法重试:缺少接收者信息", + "@cannotRetryMissingRecipient": {}, + "voiceUnavailable": "当前语音不可用", + "@voiceUnavailable": {}, + "requestingVoice": "正在请求语音", + "@requestingVoice": {}, + "device": "设备", + "change": "更改", + "wizardOverviewDescription": "此应用将 MeshCore 消息、SAR 现场更新、地图功能和设备工具整合到同一处。", + "wizardOverviewFeature1": "在主消息标签页中发送私聊消息、房间消息和频道消息。", + "wizardOverviewFeature2": "通过 mesh 网络共享 SAR 标记、地图绘图、语音片段和图像。", + "wizardOverviewFeature3": "通过 BLE 或 TCP 连接,然后直接在应用中管理配套电台。", + "wizardMessagingTitle": "消息与现场报告", + "wizardMessagingDescription": "这里的消息不只是纯文本。应用已经支持多种操作载荷和传输流程。", + "wizardMessagingFeature1": "通过一个编辑器发送私聊消息、房间消息和频道流量。", + "wizardMessagingFeature2": "为常见现场报告创建 SAR 更新和可复用的 SAR 模板。", + "wizardMessagingFeature3": "传输语音会话和图像,并在界面中显示进度和空口时间估算。", + "wizardConnectDeviceTitle": "连接设备", + "wizardConnectDeviceDescription": "连接你的 MeshCore 电台,选择名称,并在继续前应用无线电预设。", + "wizardSetupBadge": "设置", + "wizardOverviewBadge": "概览", + "wizardConnectedToDevice": "已连接到 {deviceName}", + "wizardNoDeviceConnected": "尚未连接设备", + "wizardSkipForNow": "暂时跳过", + "wizardDeviceNameLabel": "设备名称", + "wizardDeviceNameHelp": "此名称会广播给其他 MeshCore 用户。", + "wizardConfigRegionLabel": "配置区域", + "wizardConfigRegionHelp": "使用完整的官方 MeshCore 预设列表。默认值为 EU/UK (Narrow)。", + "wizardPresetNote1": "请确认所选预设符合你所在地区的无线电规定。", + "wizardPresetNote2": "该列表与 MeshCore 配置工具的官方预设源保持一致。", + "wizardPresetNote3": "在引导设置中默认保持选中 EU/UK (Narrow)。", + "wizardSaving": "正在保存...", + "wizardSaveAndContinue": "保存并继续", + "wizardEnterDeviceName": "继续前请输入设备名称。", + "wizardDeviceSetupSaved": "已保存 {deviceName},配置为 {presetName}。", + "wizardNetworkTitle": "联系人、房间和中继器", + "wizardNetworkDescription": "联系人标签页会整理你发现的网络以及随着时间学习到的路由。", + "wizardNetworkFeature1": "在一个列表中查看团队成员、中继器、房间、频道和待处理广播。", + "wizardNetworkFeature2": "当连接变得混乱时,使用智能 ping、房间登录、已学习路径和路由重置工具。", + "wizardNetworkFeature3": "无需离开应用即可创建频道并管理网络目的地。", + "wizardMapOpsTitle": "地图、轨迹与共享几何", + "wizardMapOpsDescription": "应用地图直接与消息、跟踪和 SAR 叠加层关联,而不是单独的查看器。", + "wizardMapOpsFeature1": "在地图上跟踪你自己的位置、队友位置和移动轨迹。", + "wizardMapOpsFeature2": "打开消息中的绘图,内联预览,并在需要时将其从地图上移除。", + "wizardMapOpsFeature3": "使用中继器地图视图和共享叠加层来了解现场网络覆盖范围。", + "wizardToolsTitle": "消息之外的工具", + "wizardToolsDescription": "这里不只有四个主标签页。应用还包含配置、诊断和可选的传感器工作流。", + "wizardToolsFeature1": "打开设备配置以更改无线电设置、遥测、TX 功率和配套设备详情。", + "wizardToolsFeature2": "当你需要受监控的传感器面板和快速刷新操作时,启用传感器标签页。", + "wizardToolsFeature3": "在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。", + "contactInSensors": "在传感器中", + "contactAddToSensors": "添加到传感器", + "contactSetPath": "设置路径", + "contactAddedToSensors": "已将 {contactName} 添加到传感器", + "contactFailedToClearRoute": "清除路由失败:{error}", + "contactRouteCleared": "路由已清除", + "contactRouteSet": "路由已设置:{route}", + "contactFailedToSetRoute": "设置路由失败:{error}", + "rssi": "RSSI", + "snr": "SNR", + "ackTimeout": "ACK 超时", + "opcode": "操作码", + "payload": "有效载荷", + "hops": "跳数", + "hashSize": "哈希大小", + "pathBytes": "路径字节", + "selectedPath": "选定路径", + "estimatedTx": "预计传输", + "senderToReceipt": "发送到接收", + "receivedCopies": "收到的副本", + "retryCause": "重试原因", + "retryMode": "重试模式", + "retryResult": "重试结果", + "lastRetry": "最后重试", + "rxPackets": "RX 数据包", + "mesh": "Mesh", + "rate": "速率", + "window": "窗口", + "posttxDelay": "发送后延迟", + "bandpass": "带通", + "bandpassFilterVoice": "带通语音滤波", + "active": "活动", + "addContact": "添加联系人", + "all": "全部", + "clearAllLabel": "清除全部", + "clearFilters": "清除过滤器", + "clearRoute": "清除路由", + "clearMessages": "清除消息", + "clearScale": "清除比例", + "clearDiscoveries": "清除发现", + "clearOnlineTraceDatabase": "清除在线追踪数据库", + "clearAllChannels": "清除所有频道", + "clearAllContacts": "清除所有联系人", + "clearChannels": "清除频道", + "clearContacts": "清除联系人", + "clearPathOnMaxRetry": "最大重试时清除路径", + "create": "创建", + "custom": "自定义", + "defaultValue": "默认", + "duplicate": "复制", + "editName": "编辑名称", + "open": "打开", + "paste": "粘贴", + "preview": "预览", + "remove": "移除", + "rename": "重命名", + "resolveAll": "解析全部", + "send": "发送", + "sendAnyway": "仍然发送", + "share": "分享", + "trace": "追踪", + "discovery": "发现", + "discoverRepeaters": "发现中继器", + "discoverSensors": "发现传感器", + "repeaterDiscoverySent": "中继器发现已发送", + "sensorDiscoverySent": "传感器发现已发送", + "clearedPendingDiscoveries": "已清除待处理的发现。", + "autoDiscovery": "自动发现", + "enableAutomaticAdding": "启用自动添加", + "autoaddRepeaters": "自动添加中继器", + "autoaddRoomServers": "自动添加房间服务器", + "autoaddSensors": "自动添加传感器", + "autoaddUsers": "自动添加用户", + "overwriteOldestWhenFull": "满时覆盖最旧的", + "storage": "存储", + "dangerZone": "危险区域", + "profiles": "配置文件", + "favourites": "收藏夹", + "sensors": "传感器", + "others": "其他", + "gpsModule": "GPS 模块", + "liveTraffic": "实时流量", + "repeatersMap": "中继器地图", + "blePacketLogs": "BLE 数据包日志", + "onlineTraceDatabase": "在线追踪数据库", + "routePathByteSize": "路由路径字节大小", + "messageNotifications": "消息通知", + "sarAlerts": "SAR 警报", + "discoveryNotifications": "发现通知", + "updateNotifications": "更新通知", + "muteWhileAppIsOpen": "应用打开时静音", + "disableContacts": "禁用联系人", + "enableSensorsTab": "启用传感器标签", + "enableProfiles": "启用配置文件", + "nearestRepeaterFallback": "最近中继器回退", + "deleteAllStoredMessageHistory": "删除所有存储的消息历史", + "messageFontSize": "消息字体大小", + "rotateMapWithHeading": "随航向旋转地图", + "showMapDebugInfo": "显示地图调试信息", + "openMapInFullscreen": "全屏打开地图", + "showSarMarkersLabel": "显示 SAR 标记", + "hideRepeatersOnMap": "在地图上隐藏中继器", + "setMapScale": "设置地图比例", + "customMapScaleSaved": "自定义地图比例已保存", + "voiceBitrate": "语音比特率", + "voiceCompressor": "语音压缩器", + "voiceLimiter": "语音限制器", + "micAutoGain": "麦克风自动增益", + "echoCancellation": "回声消除", + "noiseSuppression": "噪音抑制", + "trimSilenceInVoiceMessages": "修剪语音消息中的静音", + "compressor": "压缩器", + "limiter": "限制器", + "autoGain": "自动增益", + "echoCancel": "回声", + "noiseSuppress": "噪声", + "silenceTrim": "静音", + "maxImageSize": "最大图片大小", + "imageCompression": "图片压缩", + "grayscale": "灰度", + "ultraMode": "超级模式", + "fastPrivateGpsUpdates": "快速私有 GPS 更新", + "movementThreshold": "移动阈值", + "fastGpsMovementThreshold": "快速 GPS 移动阈值", + "fastGpsActiveuseInterval": "快速 GPS 活动使用间隔", + "activeuseUpdateInterval": "活动使用更新间隔", + "repeatNearbyTraffic": "转发附近流量", + "relayThroughRepeatersAcrossTheMesh": "通过网格中的中继器转发", + "nearbyOnlyWithoutRepeaterFlooding": "仅附近,无中继器泛洪", + "multihop": "多跳", + "createProfile": "创建配置文件", + "renameProfile": "重命名配置文件", + "newProfile": "新配置文件", + "manageProfiles": "管理配置文件", + "enableProfilesToStartManagingThem": "启用配置文件以开始管理它们。", + "openMessage": "打开消息", + "jumpToTheRelatedSarMessage": "跳转到相关的 SAR 消息", + "removeSarMarker": "移除 SAR 标记", + "pleaseSelectADestinationToSendSarMarker": "请选择目标以发送 SAR 标记", + "sarMarkerBroadcastToPublicChannel": "SAR 标记已广播到公共频道", + "sarMarkerSentToRoom": "SAR 标记已发送到房间", + "loadFromGallery": "从图库加载", + "replaceImage": "替换图片", + "selectFromGallery": "从图库选择", + "team": "团队", + "found": "已找到", + "staging": "集结区", + "object": "物体", + "quiet": "安静", + "busy": "繁忙", + "searchMessages": "搜索消息", + "sendImageFromGallery": "从图库发送图片", + "takePhoto": "拍照", + "allMessages": "所有消息", + "sendToPublicChannel": "发送到公共频道?", + "selectMarkerTypeAndDestination": "选择标记类型和目标", + "noDestinationsAvailableLabel": "没有可用的目标", + "image": "图片", + "format": "格式", + "dimensions": "尺寸", + "segments": "分段", + "transfers": "传输", + "downloadedBy": "已下载", + "saveDiscoverySettings": "保存发现设置", + "savePublicInfo": "保存公共信息", + "saveRadioSettings": "保存无线电设置", + "savePath": "保存路径", + "wipeDeviceData": "擦除设备数据", + "wipeDevice": "擦除设备", + "destructiveDeviceActions": "破坏性设备操作。", + "chooseAPresetOrFinetuneCustomRadioSettings": "选择预设或微调自定义无线电设置。", + "used": "已使用", + "total": "总计", + "renameValue": "重命名值", + "customizeFields": "自定义字段", + "livePreview": "实时预览", + "refreshSchedule": "刷新计划", + "noResponse": "无响应", + "refreshing": "刷新中", + "unavailable": "不可用", + "pickARelayOrNodeToWatchInSensors": "选择要在传感器中监视的中继或节点。", + "publicKeyLabel": "公钥", + "alreadyInContacts": "已在联系人中", + "connectToADeviceBeforeAddingContacts": "添加联系人前请连接到设备", + "fromContacts": "来自联系人", + "onlineOnly": "仅在线", + "inBoth": "两者中", + "source": "来源", + "allMessagesCleared": "所有消息已清除", + "onlineTraceDatabaseCleared": "在线追踪数据库已清除", + "packetLogsCleared": "数据包日志已清除", + "hexDataCopiedToClipboard": "十六进制数据已复制到剪贴板", + "developerModeEnabled": "开发者模式已启用", + "developerModeDisabled": "开发者模式已禁用", + "clipboardIsEmpty": "剪贴板为空", + "contactImported": "联系人已导入", + "contactLinkCopiedToClipboard": "联系人链接已复制到剪贴板", + "failedToExportContact": "导出联系人失败", + "noLogsToExport": "没有日志可导出", + "exportAsCsv": "导出为 CSV", + "exportAsText": "导出为文本", + "receivedRfc3339": "收到 (RFC3339)", + "buildTime": "构建时间", + "downloadUrlNotAvailable": "下载链接不可用", + "cannotOpenDownloadUrl": "无法打开下载链接", + "updateCheckIsOnlyAvailableOnAndroid": "更新检查仅在 Android 上可用", + "youAreRunningTheLatestVersion": "您正在使用最新版本", + "updateAvailableButDownloadUrlNotFound": "有更新可用但未找到下载链接", + "startTictactoe": "开始井字棋", + "tictactoeUnavailable": "井字棋不可用", + "tictactoeOpponentUnknown": "井字棋:对手未知", + "tictactoeWaitingForStart": "井字棋:等待开始", + "acceptsShareLinks": "接受分享链接", + "supportsRawHex": "支持原始十六进制", + "clipboardfriendly": "剪贴板友好", + "captured": "已捕获", + "size": "大小", + "noCustomChannelsToClear": "没有自定义频道可清除。", + "noDeviceContactsToClear": "没有设备联系人可清除。", + "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": "区域范围已清除", + "simpleMode": "简单模式", + "simpleModeDescription": "仅与收藏联系人聊天和查看地图", + "simpleModeRequiresLockedDestination": "请先启用“锁定目的地”并选择一个频道,再使用简单模式", + "noFavouriteContacts": "暂无收藏联系人", + "selectDestinationFirst": "请先选择频道、联系人或房间", + "recipientNoLongerAvailable": "收件人已不可用", + "failedToSendMessage": "发送失败:{error}", + "tictactoeDirectOnly": "井字棋仅适用于私聊消息。请先选择联系人。", + "deviceKeyUnavailable": "设备密钥不可用", + "imageCompressionFailed": "图片压缩失败", + "imageFragmentationFailed": "图片分片失败", + "failedToAnnounceImage": "图片通告失败", + "noRecipientSelected": "未选择收件人", + "imageSendFailed": "图片发送失败", + "microphonePermissionRequiredForVoice": "语音消息需要麦克风权限", + "recordingDiscarded": "已丢弃录音", + "publicChannelMediaWarning": "您即将向公共频道发送{mediaType}。不建议这样做,因为 mesh 网络中的所有人都可能收到。除非确有需要,请选择私有或带标签的频道。", + "selectPrivateChannelToShareLocation": "请选择私有频道来分享您的位置。", + "stopSharingMyLocation": "停止分享我的位置", + "shareMyLocation": "分享我的位置", + "stopRecording": "停止录音", + "recordVoice": "录制语音", + "moreActions": "更多操作", + "noMessagesInFilter": "此筛选条件下没有消息", + "noMatchesInFilter": "此筛选条件下没有匹配项", + "syncFailed": "同步失败:{error}", + "invalidLink": "无效链接", + "cannotOpenLink": "无法打开链接", + "failedToOpenLink": "打开链接失败", + "failedToResendMessage": "重新发送消息失败", + "retryFailed": "重试失败:{error}", + "deleteDrawingAndMessage": "删除绘图和消息", + "lastEchoRelay": "最后回声:中继", + "lastEchoPath": "最后回声:路径", + "lastEchoBytesReport": "最后回声:字节报告", + "noGpsCoordinatesAvailable": "没有可用的 GPS 坐标", + "notASarMarker": "不是搜救标记", + "failedToSaveTemplate": "保存模板失败:{error}", + "drawingNotFound": "未找到绘图", + "unknownDrawingType": "未知绘图类型", + "customMapMarker": "自定义地图标记", + "customMapPointLabel": "坐标点:{point}", + "mapIdLabel": "地图 ID:{id}", + "cannotFetchVoice": "无法获取语音", + "senderContactUnknown": "发送者联系人未知。请先同步联系人。", + "senderRouteUnknown": "发送者路由未知。请先同步联系人/路径。", + "messageTooFar": "消息距离过远({hops} 跳,最多 {maxHops} 跳)。", + "senderRouteNoPathResponse": "发送者路由未响应路径检测。请同步联系人/路径后重试。", + "senderRouteNoRawResponse": "发送者路由在原始传输路径上未响应。", + "voiceFetchOverHops": "通过 {hops} 跳获取语音可能需要一些时间。", + "voiceReceiveCanceled": "已取消接收语音", + "fetchingMissingVoiceFragments": "正在获取缺失的语音分片", + "receivingVoice": "正在接收语音", + "cannotFetchImage": "无法获取图片", + "imageFetchOverHops": "通过 {hops} 跳获取图片可能需要一些时间。", + "imageAlreadyBeingReceived": "图片正在接收中", + "loadImage": "加载图片", + "imageFetchFailedToSendRequest": "图片获取请求发送失败", + "imageUnavailable": "图片暂时不可用", + "imageFetchTimedOut": "图片获取超时", + "imageReceiveCanceled": "已取消接收图片", + "closeImagePreview": "关闭图片预览", + "recordingReleaseToSend": "正在录音……松开发送", + "sendingVoice": "正在发送语音……", + "sendLongPressToRecordVoice": "发送(长按录制语音)", + "discardRecording": "丢弃录音", + "hopCount": "{count, plural, other{{count} 跳}}", + "channelsSectionDescription": "用于附近 mesh 通信的广播频道", + "roomsSectionDescription": "用于团队持续协调的共享空间", + "contactsSectionDescription": "可直接联系的人员和设备", + "noRecentChatters": "最近没有人发言", + "public": "公共", + "failedToSendTicTacToeMove": "井字棋走子发送失败", + "youWon": "您赢了", + "opponentWon": "对手赢了", + "gameDraw": "平局", + "yourTurn": "轮到您", + "opponentTurn": "轮到对手", + "pleaseEnterValidCoordinates": "请输入有效坐标", + "locationNotAvailable": "位置不可用", + "customCaveMapPoint": "自定义洞穴地图点", + "mapPoint": "地图点", + "fetchingMissingImageFragments": "正在获取缺失分片……", + "loadingImage": "加载中……", + "receivingImage": "接收中……", + "tapToLoad": "点按加载", + "connectDevice": "连接设备", + "chooseTransportSubtitle": "选择 Bluetooth、WiFi 或串口传输", + "ble": "BLE", + "network": "网络", + "serial": "串口", + "scan": "扫描", + "pressScanToSearchForDevices": "点按扫描以搜索附近设备", + "failedToConnectToDevice": "连接 {name} 失败", + "unknownDevice": "未知设备", + "showingCachedResultsTapRefresh": "正在显示缓存结果。点按刷新以重新扫描。", + "scanningLocalNetworkOnPort": "正在扫描本地网络中端口 {port} 上的 MeshCore WiFi 设备", + "cancelScanAndAddServer": "取消扫描并添加服务器", + "addServer": "添加服务器", + "scanningProgressIps": "正在扫描……{scanned}/{total} 个 IP", + "cancelAndEnterManually": "取消并手动输入", + "noRecentOrDiscoveredServers": "暂无最近使用或发现的服务器", + "recentlyUsed": "最近使用", + "connecting": "正在连接……", + "discoveredOnThisNetwork": "在此网络中发现", + "serverNoLongerAvailable": "{host}:{port} 上的服务器已不可用。请重新扫描以查找活跃的服务器。", + "failedToConnectToHost": "连接 {host}:{port} 失败", + "enterValidIpAddress": "请输入有效的 IP 地址", + "enterValidTcpPort": "请输入有效的 TCP 端口", + "ipAddress": "IP 地址", + "tcpPort": "TCP 端口", + "customServerPort": "自定义服务器端口", + "chat": "聊天", + "restoringPreviousLink": "正在恢复先前的连接", + "noDeviceConnected": "未连接设备", + "sendAdvert": "发送我的联系人信息", + "advertMode": "发送我的联系人信息", + "advertModeSubtitle": "选择您的联系人信息应传播多远。", + "floodAdvertSent": "联系人信息已发送至整个 mesh 网络", + "directAdvertSent": "联系人信息已发送至附近设备" +} diff --git a/lib/l10n/untranslated.json b/lib/l10n/untranslated.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/lib/l10n/untranslated.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..82b615d --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,440 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import 'providers/connection_provider.dart'; +import 'providers/contacts_provider.dart'; +import 'providers/messages_provider.dart'; +import 'providers/map_provider.dart'; +import 'providers/drawing_provider.dart'; +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'; +import 'services/notification_service.dart'; +import 'services/locale_preferences.dart'; +import 'services/mesh_map_nodes_service.dart'; +import 'services/profile_manager.dart'; +import 'services/profile_workspace_coordinator.dart'; +import 'services/profiles_feature_service.dart'; +import 'services/update_checker_service.dart'; +import 'services/wizard_preferences.dart'; +import 'screens/discovery_screen.dart'; +import 'screens/home_screen.dart'; +import 'screens/welcome_wizard_screen.dart'; +import 'theme/app_theme.dart'; +import 'utils/toast_logger.dart'; +import 'l10n/app_localizations.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await WakelockPlus.enable(); + runApp(const MeshCoreSarApp()); +} + +class MeshCoreSarApp extends StatefulWidget { + const MeshCoreSarApp({super.key}); + + @override + State createState() => _MeshCoreSarAppState(); +} + +class _MeshCoreSarAppState extends State { + static const bool _isScreenshotRun = bool.fromEnvironment( + 'MESHCORE_SCREENSHOTS', + ); + final GlobalKey _navigatorKey = GlobalKey(); + AppThemeMode _themeMode = AppThemeMode.system; + Locale? _locale; + bool _isInitialized = false; + bool _shouldShowPermissionDialog = false; + bool _wizardCompleted = true; // Will be updated in _initializeApp() + String? _pendingNotificationPayload; + + @override + void initState() { + super.initState(); + _initializeApp(); + } + + Future _initializeApp() async { + final prefs = await SharedPreferences.getInstance(); + final profilesEnabled = await ProfilesFeatureService.isEnabled(); + final activeProfileId = + prefs.getString(ProfileManager.activeProfileIdKey) ?? 'default'; + await ProfileStorageScope.bootstrap( + profilesEnabled: profilesEnabled, + activeProfileId: activeProfileId, + ); + await _loadThemePreference(); + await _loadLocalePreference(); + + // Check if welcome wizard has been completed + final wizardCompleted = await WizardPreferences.isWizardCompleted(); + + // Initialize notification service + await NotificationService().initialize(); + + // Set up notification tap handler for update notifications + NotificationService().onNotificationTapped = _handleNotificationTap; + _pendingNotificationPayload = NotificationService().consumeLaunchPayload(); + + // Check if we need to request location permissions + if (!_isScreenshotRun) { + await _checkLocationPermissions(); + } + + // Check for app updates (Android only) - runs in background + // Shows notification if update is available + _checkForUpdates(); + + // Refresh remote mesh node cache in background when the app starts. + unawaited(MeshMapNodesService.syncInBackgroundIfStale()); + + setState(() { + _wizardCompleted = wizardCompleted; + _isInitialized = true; + }); + + if (_pendingNotificationPayload != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final payload = _pendingNotificationPayload; + _pendingNotificationPayload = null; + _handleNotificationTap(payload); + }); + } + } + + /// Handle notification tap + void _handleNotificationTap(String? payload) { + if (payload == null) return; + + debugPrint('[Main] Notification tapped: $payload'); + + if (!_wizardCompleted) { + _pendingNotificationPayload = payload; + return; + } + + // Handle update notification tap + if (payload.startsWith('update:')) { + final downloadUrl = payload.substring(7); // Remove 'update:' prefix + _launchUpdateDownload(downloadUrl); + return; + } + + if (payload.startsWith('discovery:')) { + final navigator = _navigatorKey.currentState; + if (navigator == null) { + _pendingNotificationPayload = payload; + return; + } + navigator.push( + MaterialPageRoute(builder: (context) => const DiscoveryScreen()), + ); + } + // SAR and message notifications handled by their respective providers + } + + /// Launch update download URL + Future _launchUpdateDownload(String downloadUrl) async { + try { + final url = Uri.parse(downloadUrl); + final canLaunch = await canLaunchUrl(url); + + if (!canLaunch) { + debugPrint('[Main] Cannot open download URL: $downloadUrl'); + return; + } + + await launchUrl(url, mode: LaunchMode.externalApplication); + } catch (e) { + debugPrint('[Main] Error launching download URL: $e'); + } + } + + Future _checkLocationPermissions() async { + try { + final permission = await Geolocator.checkPermission(); + + // Show dialog if permission is denied or not determined + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + _shouldShowPermissionDialog = true; + } + } catch (e) { + debugPrint('Error checking location permissions: $e'); + } + } + + Future _loadThemePreference() async { + final prefs = await SharedPreferences.getInstance(); + final themeName = prefs.getString('theme_mode') ?? 'system'; + setState(() { + _themeMode = AppTheme.themeFromString(themeName); + }); + } + + Future _loadLocalePreference() async { + final locale = await LocalePreferences.getLocale(); + setState(() { + _locale = locale; + }); + } + + void _handleThemeChanged(AppThemeMode mode) { + setState(() { + _themeMode = mode; + }); + } + + void _handleLocaleChanged(Locale? locale) { + setState(() { + _locale = locale; + }); + } + + void _handleWizardCompleted() { + setState(() { + _wizardCompleted = true; + }); + } + + /// Check for app updates on Android only + /// Shows notification if update is available + Future _checkForUpdates() async { + // Only check for updates on Android + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + debugPrint('[UpdateChecker] Skipping update check (not Android)'); + return; + } + + try { + debugPrint('[UpdateChecker] Starting update check...'); + final updateInfo = await UpdateCheckerService().checkForUpdate(); + + if (!updateInfo.isAvailable) { + debugPrint('[UpdateChecker] No update available'); + return; + } + + if (updateInfo.downloadUrl == null) { + debugPrint('[UpdateChecker] Update available but no download URL'); + return; + } + + debugPrint('[UpdateChecker] Update available! Showing notification...'); + + // Show notification (will be visible after app is initialized) + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (mounted) { + await NotificationService().showUpdateNotification( + currentVersion: updateInfo.currentCommitHash, + latestVersion: updateInfo.latestCommitHash ?? 'unknown', + downloadUrl: updateInfo.downloadUrl!, + localizations: null, // Will use English fallback + ); + } + }); + } catch (e) { + debugPrint('[UpdateChecker] Error checking for updates: $e'); + } + } + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return const MaterialApp( + home: Scaffold(body: Center(child: CircularProgressIndicator())), + ); + } + + return MultiProvider( + providers: [ + // Core providers + ChangeNotifierProvider(create: (_) => ConnectionProvider()), + ChangeNotifierProvider( + create: (_) { + // Initialize early to load persisted contacts for offline viewing + // Self-contact filtering will happen later when BLE connects + final provider = ContactsProvider(); + provider.initializeEarly(); + return provider; + }, + ), + ChangeNotifierProvider( + create: (_) { + final provider = MessagesProvider(); + // Initialize messages provider asynchronously + provider.initialize(); + return provider; + }, + ), + ChangeNotifierProvider(create: (_) => MapProvider()), + ChangeNotifierProvider( + create: (_) { + final provider = DrawingProvider(); + // Initialize drawing provider asynchronously + provider.initialize(); + return provider; + }, + ), + ChangeNotifierProvider(create: (_) => ChannelsProvider()), + ChangeNotifierProvider(create: (_) => SensorsProvider()), + ChangeNotifierProvider(create: (_) => OfflineTilesProvider()), + ChangeNotifierProvider( + create: (_) { + final manager = ProfileManager(); + manager.initialize(); + return manager; + }, + ), + + // Voice provider (packet reassembly + playback) + ChangeNotifierProvider( + create: (_) => VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ), + ), + + // Image provider (fragment reassembly + outgoing session cache) + ChangeNotifierProvider(create: (_) => ip.ImageProvider()), + + // App provider that coordinates everything + // VoiceProvider is read via context.read inside create since it's already registered above + ChangeNotifierProxyProvider5< + ConnectionProvider, + ContactsProvider, + MessagesProvider, + DrawingProvider, + ChannelsProvider, + AppProvider + >( + create: (context) => AppProvider( + connectionProvider: context.read(), + contactsProvider: context.read(), + messagesProvider: context.read(), + drawingProvider: context.read(), + channelsProvider: context.read(), + voiceProvider: context.read(), + imageProvider: context.read(), + ), + update: + ( + context, + conn, + contacts, + messages, + drawings, + channels, + previous, + ) => + previous ?? + AppProvider( + connectionProvider: conn, + contactsProvider: contacts, + messagesProvider: messages, + drawingProvider: drawings, + channelsProvider: channels, + voiceProvider: context.read(), + imageProvider: context.read(), + ), + ), + ProxyProvider6< + ProfileManager, + AppProvider, + ConnectionProvider, + ContactsProvider, + MessagesProvider, + MapProvider, + ProfileWorkspaceCoordinator + >( + update: + ( + context, + profileManager, + appProvider, + connectionProvider, + contactsProvider, + messagesProvider, + mapProvider, + previous, + ) => ProfileWorkspaceCoordinator( + profileManager: profileManager, + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + messagesProvider: messagesProvider, + sensorsProvider: context.read(), + mapProvider: mapProvider, + drawingProvider: context.read(), + channelsProvider: context.read(), + voiceProvider: context.read(), + imageProvider: context.read(), + appProvider: appProvider, + ), + ), + ], + child: _buildMaterialApp(), + ); + } + + Widget _buildMaterialApp() { + return Builder( + builder: (context) { + final systemBrightness = MediaQuery.platformBrightnessOf(context); + final materialApp = MaterialApp( + navigatorKey: _navigatorKey, + scaffoldMessengerKey: rootScaffoldMessengerKey, + key: ValueKey( + '${_locale?.languageCode ?? 'system'}_${_themeMode.name}', + ), + onGenerateTitle: (context) => AppLocalizations.of(context)?.appTitle ?? 'MeshCore SAR', + debugShowCheckedModeBanner: false, + theme: AppTheme.getTheme(_themeMode, systemBrightness), + locale: _locale, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: LocalePreferences.supportedLocales, + home: _wizardCompleted + ? HomeScreen( + onThemeChanged: _handleThemeChanged, + onLocaleChanged: _handleLocaleChanged, + currentTheme: _themeMode, + currentLocale: _locale, + shouldShowPermissionDialog: _shouldShowPermissionDialog, + ) + : WelcomeWizardScreen(onCompleted: _handleWizardCompleted), + ); + + // Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36) + // iOS doesn't need SafeArea wrapping (causes extra black space at bottom) + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + return SafeArea( + left: false, + right: false, + top: false, // prevents black status bar background + child: materialApp, + ); + } + + return materialApp; + }, + ); + } +} diff --git a/lib/models/advert_location.dart b/lib/models/advert_location.dart new file mode 100644 index 0000000..6a2b55d --- /dev/null +++ b/lib/models/advert_location.dart @@ -0,0 +1 @@ +export 'package:meshcore_client/meshcore_client.dart' show AdvertLocation; diff --git a/lib/models/ble_packet_log.dart b/lib/models/ble_packet_log.dart new file mode 100644 index 0000000..5c92ee3 --- /dev/null +++ b/lib/models/ble_packet_log.dart @@ -0,0 +1,2 @@ +export 'package:meshcore_client/meshcore_client.dart' + show BlePacketLog, PacketDirection, LogRxDataInfo; diff --git a/lib/models/channel.dart b/lib/models/channel.dart new file mode 100644 index 0000000..03ae8dc --- /dev/null +++ b/lib/models/channel.dart @@ -0,0 +1,204 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; + +/// Channel model - represents a communication channel +/// +/// Supports two types of channels: +/// 1. Hash-based channels: Names starting with '#' (e.g., '#team', '#sar-ops') +/// - Secrets are auto-generated using SHA256(name) +/// - Same name produces same secret on all devices +/// 2. Normal channels: Any name with explicit secret +/// - User provides explicit 16-byte secret +/// - Only known to those who share the secret +class Channel { + final int index; // 0-255 + final String name; + final Uint8List secret; // 16 bytes + final int? flags; + + Channel({ + required this.index, + required this.name, + required this.secret, + this.flags, + }) { + if (secret.length != 16) { + throw ArgumentError('Channel secret must be exactly 16 bytes'); + } + if (index < 0 || index > 255) { + throw ArgumentError('Channel index must be 0-255'); + } + } + + /// Create a channel with auto-generated secret for #channels + /// + /// For #channels (name starting with '#'): + /// - Secret is auto-generated using SHA256(name)[0:16] + /// - Deterministic: same name = same secret across all devices + /// + /// For normal channels: + /// - Must provide explicit 16-byte secret + factory Channel.create({ + required int index, + required String name, + Uint8List? explicitSecret, + int? flags, + }) { + if (name.startsWith('#')) { + // Hash-based channel: auto-generate secret from name + if (explicitSecret != null) { + throw ArgumentError( + 'Cannot provide explicit secret for #channel. Secret is auto-generated.', + ); + } + final secret = _generateHashChannelSecret(name); + return Channel(index: index, name: name, secret: secret, flags: flags); + } else { + // Normal channel: require explicit secret + if (explicitSecret == null || explicitSecret.length != 16) { + throw ArgumentError('Normal channels require a 16-byte secret'); + } + return Channel( + index: index, + name: name, + secret: explicitSecret, + flags: flags, + ); + } + } + + /// Generate secret for #channel using SHA256 + /// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16] + static Uint8List _generateHashChannelSecret(String channelName) { + final bytes = utf8.encode(channelName); + final digest = sha256.convert(bytes); + 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() { + return 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, + ]), + flags: null, + ); + } + + /// 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 { + if (index == 0) { + return name.isEmpty ? 'Public' : name; + } + return name.isEmpty ? 'Channel $index' : name; + } + + /// Check if channel is the public channel (index 0) + bool get isPublicChannel => index == 0; + + /// Check if channel has a custom name + bool get hasCustomName => name.isNotEmpty; + + /// Create from JSON + factory Channel.fromJson(Map json) { + return Channel( + index: json['index'] as int, + name: json['name'] as String? ?? '', + secret: base64.decode(json['secret'] as String), + flags: json['flags'] as int?, + ); + } + + /// Convert to JSON + Map toJson() { + return { + 'index': index, + 'name': name, + 'secret': base64.encode(secret), + 'flags': flags, + }; + } + + /// Create a copy with modified fields + Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) { + return Channel( + index: index ?? this.index, + name: name ?? this.name, + secret: secret ?? this.secret, + flags: flags ?? this.flags, + ); + } + + @override + String toString() { + return 'Channel(index: $index, name: $name, isHashChannel: $isHashChannel, flags: $flags)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Channel && + other.index == index && + other.name == name && + _secretsEqual(other.secret, secret) && + other.flags == flags; + } + + @override + int get hashCode => Object.hash(index, name, secret, flags); + + bool _secretsEqual(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/lib/models/config_profile.dart b/lib/models/config_profile.dart new file mode 100644 index 0000000..66d0da3 --- /dev/null +++ b/lib/models/config_profile.dart @@ -0,0 +1,518 @@ +import 'package:flutter/foundation.dart'; +import 'channel.dart'; + +@immutable +class DeviceConfigProfileSection { + final int? frequencyKhz; + final int? bandwidth; + final int? spreadingFactor; + final int? codingRate; + final bool? repeatEnabled; + final int? txPower; + final int? telemetryModes; + final int? advertLocationPolicy; + final int? multiAcks; + final bool? manualAddContacts; + final bool? autoAddUsers; + final bool? autoAddRepeaters; + final bool? autoAddRoomServers; + final bool? autoAddSensors; + final bool? autoAddOverwriteOldest; + final double? publicLatitude; + final double? publicLongitude; + + const DeviceConfigProfileSection({ + this.frequencyKhz, + this.bandwidth, + this.spreadingFactor, + this.codingRate, + this.repeatEnabled, + this.txPower, + this.telemetryModes, + this.advertLocationPolicy, + this.multiAcks, + this.manualAddContacts, + this.autoAddUsers, + this.autoAddRepeaters, + this.autoAddRoomServers, + this.autoAddSensors, + this.autoAddOverwriteOldest, + this.publicLatitude, + this.publicLongitude, + }); + + bool get isEmpty => + frequencyKhz == null && + bandwidth == null && + spreadingFactor == null && + codingRate == null && + repeatEnabled == null && + txPower == null && + telemetryModes == null && + advertLocationPolicy == null && + multiAcks == null && + manualAddContacts == null && + autoAddUsers == null && + autoAddRepeaters == null && + autoAddRoomServers == null && + autoAddSensors == null && + autoAddOverwriteOldest == null && + publicLatitude == null && + publicLongitude == null; + + Map toJson() => { + 'frequencyKhz': frequencyKhz, + 'bandwidth': bandwidth, + 'spreadingFactor': spreadingFactor, + 'codingRate': codingRate, + 'repeatEnabled': repeatEnabled, + 'txPower': txPower, + 'telemetryModes': telemetryModes, + 'advertLocationPolicy': advertLocationPolicy, + 'multiAcks': multiAcks, + 'manualAddContacts': manualAddContacts, + 'autoAddUsers': autoAddUsers, + 'autoAddRepeaters': autoAddRepeaters, + 'autoAddRoomServers': autoAddRoomServers, + 'autoAddSensors': autoAddSensors, + 'autoAddOverwriteOldest': autoAddOverwriteOldest, + 'publicLatitude': publicLatitude, + 'publicLongitude': publicLongitude, + }; + + factory DeviceConfigProfileSection.fromJson(Map json) { + return DeviceConfigProfileSection( + frequencyKhz: json['frequencyKhz'] as int?, + bandwidth: json['bandwidth'] as int?, + spreadingFactor: json['spreadingFactor'] as int?, + codingRate: json['codingRate'] as int?, + repeatEnabled: json['repeatEnabled'] as bool?, + txPower: json['txPower'] as int?, + telemetryModes: json['telemetryModes'] as int?, + advertLocationPolicy: json['advertLocationPolicy'] as int?, + multiAcks: json['multiAcks'] as int?, + manualAddContacts: json['manualAddContacts'] as bool?, + autoAddUsers: json['autoAddUsers'] as bool?, + autoAddRepeaters: json['autoAddRepeaters'] as bool?, + autoAddRoomServers: json['autoAddRoomServers'] as bool?, + autoAddSensors: json['autoAddSensors'] as bool?, + autoAddOverwriteOldest: json['autoAddOverwriteOldest'] as bool?, + publicLatitude: (json['publicLatitude'] as num?)?.toDouble(), + publicLongitude: (json['publicLongitude'] as num?)?.toDouble(), + ); + } +} + +@immutable +class AppSettingsProfileSection { + final bool? mapEnabled; + final bool? contactsEnabled; + final bool? sensorsEnabled; + final bool? voiceSilenceTrimmingEnabled; + final bool? voiceBandPassFilterEnabled; + final bool? voiceCompressorEnabled; + final bool? voiceLimiterEnabled; + final bool? voiceAutoGainEnabled; + final bool? voiceEchoCancellationEnabled; + final bool? voiceNoiseSuppressionEnabled; + final double? messageFontScale; + final bool? clearPathOnMaxRetry; + final bool? nearestRelayFallbackEnabled; + final int? voiceBitrate; + final int? routeHashSize; + final int? imageMaxSize; + final int? imageCompression; + final bool? imageGrayscale; + final bool? imageUltraMode; + final bool? showRxTxIndicators; + final bool? fastLocationUpdatesEnabled; + final double? fastLocationMovementThresholdMeters; + final int? fastLocationActiveCadenceSeconds; + + const AppSettingsProfileSection({ + this.mapEnabled, + this.contactsEnabled, + this.sensorsEnabled, + this.voiceSilenceTrimmingEnabled, + this.voiceBandPassFilterEnabled, + this.voiceCompressorEnabled, + this.voiceLimiterEnabled, + this.voiceAutoGainEnabled, + this.voiceEchoCancellationEnabled, + this.voiceNoiseSuppressionEnabled, + this.messageFontScale, + this.clearPathOnMaxRetry, + this.nearestRelayFallbackEnabled, + this.voiceBitrate, + this.routeHashSize, + this.imageMaxSize, + this.imageCompression, + this.imageGrayscale, + this.imageUltraMode, + this.showRxTxIndicators, + this.fastLocationUpdatesEnabled, + this.fastLocationMovementThresholdMeters, + this.fastLocationActiveCadenceSeconds, + }); + + bool get isEmpty => + mapEnabled == null && + contactsEnabled == null && + sensorsEnabled == null && + voiceSilenceTrimmingEnabled == null && + voiceBandPassFilterEnabled == null && + voiceCompressorEnabled == null && + voiceLimiterEnabled == null && + voiceAutoGainEnabled == null && + voiceEchoCancellationEnabled == null && + voiceNoiseSuppressionEnabled == null && + messageFontScale == null && + clearPathOnMaxRetry == null && + nearestRelayFallbackEnabled == null && + voiceBitrate == null && + routeHashSize == null && + imageMaxSize == null && + imageCompression == null && + imageGrayscale == null && + imageUltraMode == null && + showRxTxIndicators == null && + fastLocationUpdatesEnabled == null && + fastLocationMovementThresholdMeters == null && + fastLocationActiveCadenceSeconds == null; + + Map toJson() => { + 'mapEnabled': mapEnabled, + 'contactsEnabled': contactsEnabled, + 'sensorsEnabled': sensorsEnabled, + 'voiceSilenceTrimmingEnabled': voiceSilenceTrimmingEnabled, + 'voiceBandPassFilterEnabled': voiceBandPassFilterEnabled, + 'voiceCompressorEnabled': voiceCompressorEnabled, + 'voiceLimiterEnabled': voiceLimiterEnabled, + 'voiceAutoGainEnabled': voiceAutoGainEnabled, + 'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled, + 'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled, + 'messageFontScale': messageFontScale, + 'clearPathOnMaxRetry': clearPathOnMaxRetry, + 'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled, + 'voiceBitrate': voiceBitrate, + 'routeHashSize': routeHashSize, + 'imageMaxSize': imageMaxSize, + 'imageCompression': imageCompression, + 'imageGrayscale': imageGrayscale, + 'imageUltraMode': imageUltraMode, + 'showRxTxIndicators': showRxTxIndicators, + 'fastLocationUpdatesEnabled': fastLocationUpdatesEnabled, + 'fastLocationMovementThresholdMeters': fastLocationMovementThresholdMeters, + 'fastLocationActiveCadenceSeconds': fastLocationActiveCadenceSeconds, + }; + + factory AppSettingsProfileSection.fromJson(Map json) { + return AppSettingsProfileSection( + mapEnabled: json['mapEnabled'] as bool?, + contactsEnabled: json['contactsEnabled'] as bool?, + sensorsEnabled: json['sensorsEnabled'] as bool?, + voiceSilenceTrimmingEnabled: json['voiceSilenceTrimmingEnabled'] as bool?, + voiceBandPassFilterEnabled: json['voiceBandPassFilterEnabled'] as bool?, + voiceCompressorEnabled: json['voiceCompressorEnabled'] as bool?, + voiceLimiterEnabled: json['voiceLimiterEnabled'] as bool?, + voiceAutoGainEnabled: json['voiceAutoGainEnabled'] as bool?, + voiceEchoCancellationEnabled: + json['voiceEchoCancellationEnabled'] as bool?, + voiceNoiseSuppressionEnabled: + json['voiceNoiseSuppressionEnabled'] as bool?, + messageFontScale: (json['messageFontScale'] as num?)?.toDouble(), + clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?, + nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?, + voiceBitrate: json['voiceBitrate'] as int?, + routeHashSize: json['routeHashSize'] as int?, + imageMaxSize: json['imageMaxSize'] as int?, + imageCompression: json['imageCompression'] as int?, + imageGrayscale: json['imageGrayscale'] as bool?, + imageUltraMode: json['imageUltraMode'] as bool?, + showRxTxIndicators: json['showRxTxIndicators'] as bool?, + fastLocationUpdatesEnabled: json['fastLocationUpdatesEnabled'] as bool?, + fastLocationMovementThresholdMeters: + (json['fastLocationMovementThresholdMeters'] as num?)?.toDouble(), + fastLocationActiveCadenceSeconds: + json['fastLocationActiveCadenceSeconds'] as int?, + ); + } +} + +@immutable +class MapWorkspaceProfileSection { + final Map? mapPrefs; + final List> drawings; + final Map? currentTrail; + final List> trailHistory; + final Map? importedTrail; + final bool? isTrailVisible; + final bool? showCadastralOverlay; + final bool? showForestRoadsOverlay; + final bool? showHikingTrailsOverlay; + final bool? showMainRoadsOverlay; + final bool? showHouseNumbersOverlay; + final bool? showFireHazardZonesOverlay; + final bool? showHistoricalFiresOverlay; + final bool? showFirebreaksOverlay; + final bool? showKrasFireZonesOverlay; + final bool? showPlaceNamesOverlay; + final bool? showMunicipalityBordersOverlay; + final bool? hideRepeatersOnMap; + + const MapWorkspaceProfileSection({ + this.mapPrefs, + this.drawings = const [], + this.currentTrail, + this.trailHistory = const [], + this.importedTrail, + this.isTrailVisible, + this.showCadastralOverlay, + this.showForestRoadsOverlay, + this.showHikingTrailsOverlay, + this.showMainRoadsOverlay, + this.showHouseNumbersOverlay, + this.showFireHazardZonesOverlay, + this.showHistoricalFiresOverlay, + this.showFirebreaksOverlay, + this.showKrasFireZonesOverlay, + this.showPlaceNamesOverlay, + this.showMunicipalityBordersOverlay, + this.hideRepeatersOnMap, + }); + + bool get isEmpty => + mapPrefs == null && + drawings.isEmpty && + currentTrail == null && + trailHistory.isEmpty && + importedTrail == null && + isTrailVisible == null && + showCadastralOverlay == null && + showForestRoadsOverlay == null && + showHikingTrailsOverlay == null && + showMainRoadsOverlay == null && + showHouseNumbersOverlay == null && + showFireHazardZonesOverlay == null && + showHistoricalFiresOverlay == null && + showFirebreaksOverlay == null && + showKrasFireZonesOverlay == null && + showPlaceNamesOverlay == null && + showMunicipalityBordersOverlay == null && + hideRepeatersOnMap == null; + + Map toJson() => { + 'mapPrefs': mapPrefs, + 'drawings': drawings, + 'currentTrail': currentTrail, + 'trailHistory': trailHistory, + 'importedTrail': importedTrail, + 'isTrailVisible': isTrailVisible, + 'showCadastralOverlay': showCadastralOverlay, + 'showForestRoadsOverlay': showForestRoadsOverlay, + 'showHikingTrailsOverlay': showHikingTrailsOverlay, + 'showMainRoadsOverlay': showMainRoadsOverlay, + 'showHouseNumbersOverlay': showHouseNumbersOverlay, + 'showFireHazardZonesOverlay': showFireHazardZonesOverlay, + 'showHistoricalFiresOverlay': showHistoricalFiresOverlay, + 'showFirebreaksOverlay': showFirebreaksOverlay, + 'showKrasFireZonesOverlay': showKrasFireZonesOverlay, + 'showPlaceNamesOverlay': showPlaceNamesOverlay, + 'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay, + 'hideRepeatersOnMap': hideRepeatersOnMap, + }; + + factory MapWorkspaceProfileSection.fromJson(Map json) { + List> decodeList(String key) { + final raw = json[key] as List? ?? const []; + return raw.whereType>().toList(); + } + + return MapWorkspaceProfileSection( + mapPrefs: json['mapPrefs'] as Map?, + drawings: decodeList('drawings'), + currentTrail: json['currentTrail'] as Map?, + trailHistory: decodeList('trailHistory'), + importedTrail: json['importedTrail'] as Map?, + isTrailVisible: json['isTrailVisible'] as bool?, + showCadastralOverlay: json['showCadastralOverlay'] as bool?, + showForestRoadsOverlay: json['showForestRoadsOverlay'] as bool?, + showHikingTrailsOverlay: json['showHikingTrailsOverlay'] as bool?, + showMainRoadsOverlay: json['showMainRoadsOverlay'] as bool?, + showHouseNumbersOverlay: json['showHouseNumbersOverlay'] as bool?, + showFireHazardZonesOverlay: json['showFireHazardZonesOverlay'] as bool?, + showHistoricalFiresOverlay: json['showHistoricalFiresOverlay'] as bool?, + showFirebreaksOverlay: json['showFirebreaksOverlay'] as bool?, + showKrasFireZonesOverlay: json['showKrasFireZonesOverlay'] as bool?, + showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?, + showMunicipalityBordersOverlay: + json['showMunicipalityBordersOverlay'] as bool?, + hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?, + ); + } +} + +@immutable +class ConfigProfileSections { + final DeviceConfigProfileSection? deviceConfig; + final AppSettingsProfileSection? appSettings; + final MapWorkspaceProfileSection? mapWorkspace; + final List channels; + + const ConfigProfileSections({ + this.deviceConfig, + this.appSettings, + this.mapWorkspace, + this.channels = const [], + }); + + bool get isEmpty => + (deviceConfig == null || deviceConfig!.isEmpty) && + (appSettings == null || appSettings!.isEmpty) && + (mapWorkspace == null || mapWorkspace!.isEmpty) && + channels.isEmpty; + + Map toJson() => { + 'deviceConfig': deviceConfig?.toJson(), + 'appSettings': appSettings?.toJson(), + 'mapWorkspace': mapWorkspace?.toJson(), + 'channels': channels.map((channel) => channel.toJson()).toList(), + }; + + factory ConfigProfileSections.fromJson(Map json) { + final rawChannels = json['channels'] as List? ?? const []; + return ConfigProfileSections( + deviceConfig: json['deviceConfig'] is Map + ? DeviceConfigProfileSection.fromJson( + json['deviceConfig'] as Map, + ) + : null, + appSettings: json['appSettings'] is Map + ? AppSettingsProfileSection.fromJson( + json['appSettings'] as Map, + ) + : null, + mapWorkspace: json['mapWorkspace'] is Map + ? MapWorkspaceProfileSection.fromJson( + json['mapWorkspace'] as Map, + ) + : null, + channels: rawChannels + .whereType>() + .map(Channel.fromJson) + .toList(), + ); + } +} + +@immutable +class ConfigProfile { + static const String defaultProfileId = 'default'; + + final String id; + final String name; + final DateTime createdAt; + final DateTime updatedAt; + final String? notes; + final ConfigProfileSections sections; + + const ConfigProfile({ + required this.id, + required this.name, + required this.createdAt, + required this.updatedAt, + required this.sections, + this.notes, + }); + + bool get isDefault => id == defaultProfileId; + + factory ConfigProfile.defaultProfile() { + final now = DateTime.now(); + return ConfigProfile( + id: defaultProfileId, + name: 'Default', + createdAt: now, + updatedAt: now, + sections: const ConfigProfileSections(), + ); + } + + ConfigProfile copyWith({ + String? id, + String? name, + DateTime? createdAt, + DateTime? updatedAt, + String? notes, + ConfigProfileSections? sections, + }) { + return ConfigProfile( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + notes: notes ?? this.notes, + sections: sections ?? this.sections, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'notes': notes, + 'sections': sections.toJson(), + }; + + factory ConfigProfile.fromJson(Map json) { + return ConfigProfile( + id: json['id'] as String, + name: json['name'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + notes: json['notes'] as String?, + sections: ConfigProfileSections.fromJson( + json['sections'] as Map? ?? const {}, + ), + ); + } +} + +@immutable +class ProfileDiff { + final List changedSections; + + const ProfileDiff({required this.changedSections}); +} + +@immutable +class ProfileTransferRecord { + final String profileId; + final String direction; + final DateTime timestamp; + final String detail; + + const ProfileTransferRecord({ + required this.profileId, + required this.direction, + required this.timestamp, + required this.detail, + }); + + Map toJson() => { + 'profileId': profileId, + 'direction': direction, + 'timestamp': timestamp.toIso8601String(), + 'detail': detail, + }; + + factory ProfileTransferRecord.fromJson(Map json) { + return ProfileTransferRecord( + profileId: json['profileId'] as String, + direction: json['direction'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + detail: json['detail'] as String, + ); + } +} diff --git a/lib/models/contact.dart b/lib/models/contact.dart new file mode 100644 index 0000000..39b4f2a --- /dev/null +++ b/lib/models/contact.dart @@ -0,0 +1,309 @@ +export 'package:meshcore_client/meshcore_client.dart' + show Contact, ContactType, ContactTelemetry, AdvertLocation; + +import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; +import 'package:meshcore_client/meshcore_client.dart'; + +class ParsedContactRoute { + final String canonicalText; + final int hashSize; + final int hopCount; + final int encodedPathLen; + final int signedEncodedPathLen; + final Uint8List pathBytes; + final Uint8List paddedPathBytes; + + const ParsedContactRoute({ + required this.canonicalText, + required this.hashSize, + required this.hopCount, + required this.encodedPathLen, + required this.signedEncodedPathLen, + required this.pathBytes, + required this.paddedPathBytes, + }); + + int get byteLength => pathBytes.length; + String get summary => hopCount == 0 + ? 'Direct' + : '$hopCount hop${hopCount == 1 ? '' : 's'} via $hashSize-byte hashes'; +} + +class ContactRouteFormatException implements Exception { + final String message; + + const ContactRouteFormatException(this.message); + + @override + String toString() => message; +} + +class ContactRouteCodec { + static const int maxHashSize = 3; + static const int maxPathBytes = 64; + static const int _unknownDescriptor = 0xFF; + + static ParsedContactRoute parse(String input, {int? expectedHashSize}) { + final normalized = input.trim().toUpperCase(); + if (normalized.isEmpty) { + throw const ContactRouteFormatException('Route cannot be empty.'); + } + + if (expectedHashSize != null && + (expectedHashSize < 1 || expectedHashSize > maxHashSize)) { + throw const ContactRouteFormatException( + 'Hash size must be 1, 2, or 3 bytes.', + ); + } + + final hopTokens = normalized + .split(',') + .map((token) => token.trim()) + .toList(); + if (hopTokens.any((token) => token.isEmpty)) { + throw const ContactRouteFormatException('Route contains an empty hop.'); + } + + final hopBytes = >[]; + int? hashSize; + for (final token in hopTokens) { + final compact = token.replaceAll(':', ''); + if (compact.isEmpty || !RegExp(r'^[0-9A-F]+$').hasMatch(compact)) { + throw ContactRouteFormatException('Invalid hop "$token".'); + } + if (compact.length.isOdd) { + throw ContactRouteFormatException( + 'Hop "$token" must contain full bytes.', + ); + } + + final currentHashSize = compact.length ~/ 2; + if (currentHashSize < 1 || currentHashSize > maxHashSize) { + throw ContactRouteFormatException( + 'Hop "$token" must be 1, 2, or 3 bytes.', + ); + } + + if (expectedHashSize != null && currentHashSize != expectedHashSize) { + throw ContactRouteFormatException( + 'Hop "$token" must be $expectedHashSize ' + 'byte${expectedHashSize == 1 ? '' : 's'}.', + ); + } + + hashSize ??= currentHashSize; + if (hashSize != currentHashSize) { + throw const ContactRouteFormatException( + 'All hops in a route must use the same hash size.', + ); + } + + final bytes = []; + for (var i = 0; i < compact.length; i += 2) { + bytes.add(int.parse(compact.substring(i, i + 2), radix: 16)); + } + hopBytes.add(bytes); + } + + final resolvedHashSize = hashSize ?? 1; + final flatBytes = Uint8List.fromList( + hopBytes.expand((hop) => hop).toList(), + ); + if (flatBytes.length > maxPathBytes) { + throw const ContactRouteFormatException( + 'Route exceeds the 64-byte firmware limit.', + ); + } + + final encodedPathLen = + ((resolvedHashSize - 1) << 6) | (hopBytes.length & 0x3F); + final padded = Uint8List(maxPathBytes); + padded.setRange(0, flatBytes.length, flatBytes); + + return ParsedContactRoute( + canonicalText: hopBytes + .map( + (hop) => hop + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(), + ) + .join(','), + hashSize: resolvedHashSize, + hopCount: hopBytes.length, + encodedPathLen: encodedPathLen, + signedEncodedPathLen: toSignedDescriptor(encodedPathLen), + pathBytes: flatBytes, + paddedPathBytes: padded, + ); + } + + static ParsedContactRoute direct({int hashSize = 1}) { + if (hashSize < 1 || hashSize > maxHashSize) { + throw ArgumentError.value(hashSize, 'hashSize', 'Must be 1, 2, or 3.'); + } + + final encodedPathLen = (hashSize - 1) << 6; + return ParsedContactRoute( + canonicalText: '', + hashSize: hashSize, + hopCount: 0, + encodedPathLen: encodedPathLen, + signedEncodedPathLen: toSignedDescriptor(encodedPathLen), + pathBytes: Uint8List(0), + paddedPathBytes: Uint8List(maxPathBytes), + ); + } + + static ParsedContactRoute? fromContact(Contact contact) { + if (!contact.routeHasPath || contact.routeHopCount == 0) { + return null; + } + + return ParsedContactRoute( + canonicalText: contact.routeCanonicalText, + hashSize: contact.routeHashSize, + hopCount: contact.routeHopCount, + encodedPathLen: contact.routeEncodedPathLen, + signedEncodedPathLen: contact.routeSignedPathLen, + pathBytes: contact.routePathBytes, + paddedPathBytes: _padPath(contact.routePathBytes), + ); + } + + static Uint8List _padPath(Uint8List bytes) { + final padded = Uint8List(maxPathBytes); + padded.setRange(0, math.min(bytes.length, maxPathBytes), bytes); + return padded; + } + + static int toSignedDescriptor(int encodedPathLen) => + encodedPathLen > 127 ? encodedPathLen - 256 : encodedPathLen; + + static int toUnsignedDescriptor(int signedPathLen) => signedPathLen & 0xFF; + + static bool isUnknownDescriptor(int signedPathLen) => + toUnsignedDescriptor(signedPathLen) == _unknownDescriptor; + + static bool isValidDescriptor(int signedPathLen) { + final raw = toUnsignedDescriptor(signedPathLen); + if (raw == _unknownDescriptor) return false; + final hashSize = ((raw >> 6) + 1); + if (hashSize > maxHashSize) return false; + final hopCount = raw & 0x3F; + return hopCount * hashSize <= maxPathBytes; + } + + /// Converts an encoded MeshCore route descriptor + path into the legacy raw + /// path format expected by the `CMD_SEND_RAW_DATA` (0x19) handler in + /// companion firmware v1.15. + /// + /// The raw-data handler treats the path-length byte as a *literal* hop count + /// (one byte per hop) rather than as an encoded descriptor, so a 1-hop route + /// using 2-byte hashes (descriptor `0x41`) would otherwise be misread as + /// "65 path bytes" and rejected with `ERROR: Unsupported command`. We + /// down-sample every hop to its first hash byte and emit a plain hop count. + /// + /// Routes that are already legacy (hash size 1, descriptor `< 0x40`) pass + /// through unchanged. Unknown/flood descriptors collapse to a zero-hop + /// direct path (raw transport does not support flood routing). + static LegacyRawPath toLegacyRawPath(int descriptor, Uint8List path) { + final raw = toUnsignedDescriptor(descriptor); + if (raw == _unknownDescriptor) { + return const LegacyRawPath(length: 0, path: []); + } + if (raw < 0x40) { + // Hash size 1: the encoded path is already one byte per hop. + final hopCount = raw; + final available = math.min(hopCount, path.length); + return LegacyRawPath( + length: hopCount, + path: Uint8List.fromList(path.sublist(0, available)), + ); + } + final hopCount = raw & 0x3F; + final hashSize = ((raw >> 6) & 0x03) + 1; + final legacy = Uint8List(hopCount); + for (var hop = 0; hop < hopCount; hop++) { + final srcIndex = hop * hashSize; + if (srcIndex < path.length) { + legacy[hop] = path[srcIndex]; + } + } + return LegacyRawPath(length: hopCount, path: legacy); + } +} + +/// Legacy raw-transport path: a literal hop count plus one byte per hop, as +/// expected by the firmware `CMD_SEND_RAW_DATA` handler. +class LegacyRawPath { + const LegacyRawPath({required this.length, required this.path}); + + final int length; + final List path; +} + +extension ContactLocalization on Contact { + /// Returns the localized display name for special contacts (e.g. Public Channel). + /// For all other contacts, returns [displayName]. + String getLocalizedDisplayName(BuildContext context) { + if (isPublicChannel) { + return AppLocalizations.of(context)!.publicChannel; + } + return displayName; + } + + int get routeEncodedPathLen => + ContactRouteCodec.toUnsignedDescriptor(outPathLen); + + int get routeSignedPathLen => + ContactRouteCodec.toSignedDescriptor(routeEncodedPathLen); + + bool get routeIsUnknown => ContactRouteCodec.isUnknownDescriptor(outPathLen); + + bool get routeHasPath => + !routeIsUnknown && ContactRouteCodec.isValidDescriptor(outPathLen); + + int get routeHashSize => routeHasPath ? ((routeEncodedPathLen >> 6) + 1) : 1; + + int get routeHopCount => routeHasPath ? (routeEncodedPathLen & 0x3F) : -1; + + int get routeByteLength => routeHasPath + ? math.min(routeHopCount * routeHashSize, outPath.length) + : 0; + + Uint8List get routePathBytes => routeByteLength <= 0 + ? Uint8List(0) + : Uint8List.fromList(outPath.sublist(0, routeByteLength)); + + String get routeCanonicalText { + if (!routeHasPath || routeHopCount <= 0) return ''; + final bytes = routePathBytes; + final hops = []; + for (var i = 0; i < bytes.length; i += routeHashSize) { + hops.add( + bytes + .sublist(i, i + routeHashSize) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(), + ); + } + return hops.join(','); + } + + String get routeSummary { + if (routeIsUnknown) { + return 'Flood/Unknown'; + } + if (!routeHasPath || routeHopCount == 0) { + return 'Direct'; + } + return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes'; + } + + bool get routeSupportsLegacyRawTransport => routeHasPath; +} diff --git a/lib/models/contact_group.dart b/lib/models/contact_group.dart new file mode 100644 index 0000000..74326a1 --- /dev/null +++ b/lib/models/contact_group.dart @@ -0,0 +1,39 @@ +class SavedContactGroup { + final String id; + final String sectionKey; + final String label; + final String query; + final DateTime createdAt; + final List? matchPrefixes; + final bool isAutoGroup; + + const SavedContactGroup({ + required this.id, + required this.sectionKey, + required this.label, + required this.query, + required this.createdAt, + this.matchPrefixes, + this.isAutoGroup = false, + }); + + SavedContactGroup copyWith({ + String? id, + String? sectionKey, + String? label, + String? query, + DateTime? createdAt, + List? matchPrefixes, + bool? isAutoGroup, + }) { + return SavedContactGroup( + id: id ?? this.id, + sectionKey: sectionKey ?? this.sectionKey, + label: label ?? this.label, + query: query ?? this.query, + createdAt: createdAt ?? this.createdAt, + matchPrefixes: matchPrefixes ?? this.matchPrefixes, + isAutoGroup: isAutoGroup ?? this.isAutoGroup, + ); + } +} diff --git a/lib/models/contact_telemetry.dart b/lib/models/contact_telemetry.dart new file mode 100644 index 0000000..ad20add --- /dev/null +++ b/lib/models/contact_telemetry.dart @@ -0,0 +1 @@ +export 'package:meshcore_client/meshcore_client.dart' show ContactTelemetry; diff --git a/lib/models/custom_map_config.dart b/lib/models/custom_map_config.dart new file mode 100644 index 0000000..57782a0 --- /dev/null +++ b/lib/models/custom_map_config.dart @@ -0,0 +1,151 @@ +import 'dart:math' as math; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import '../utils/custom_map_id.dart'; + +class CustomMapConfig { + final String filePath; + final String displayName; + final String mapId; + final int imageWidth; + final int imageHeight; + final double? metersPerPixel; + final LatLng? calibrationPointA; + final LatLng? calibrationPointB; + + const CustomMapConfig({ + required this.filePath, + required this.displayName, + required this.mapId, + required this.imageWidth, + required this.imageHeight, + this.metersPerPixel, + this.calibrationPointA, + this.calibrationPointB, + }); + + bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0; + + double get _displayScale { + final heightScale = imageHeight / 90.0; + final widthScale = imageWidth / 180.0; + return math.max(1.0, math.max(heightScale, widthScale)); + } + + double get _displayHeight => imageHeight / _displayScale; + + double get _displayWidth => imageWidth / _displayScale; + + LatLngBounds get bounds => + LatLngBounds(const LatLng(0, 0), LatLng(_displayHeight, _displayWidth)); + + LatLngBounds get displayBounds => + LatLngBounds(LatLng(_displayHeight, 0), LatLng(0, _displayWidth)); + + LatLng toDisplayPoint(LatLng point) { + return LatLng( + _displayHeight - (point.latitude / _displayScale), + point.longitude / _displayScale, + ); + } + + LatLng fromDisplayPoint(LatLng point) { + return LatLng( + (_displayHeight - point.latitude) * _displayScale, + point.longitude * _displayScale, + ); + } + + Map toJson() { + return { + 'filePath': filePath, + 'displayName': displayName, + 'mapId': mapId, + 'imageWidth': imageWidth, + 'imageHeight': imageHeight, + 'metersPerPixel': metersPerPixel, + 'calibrationPointA': calibrationPointA != null + ? { + 'lat': calibrationPointA!.latitude, + 'lon': calibrationPointA!.longitude, + } + : null, + 'calibrationPointB': calibrationPointB != null + ? { + 'lat': calibrationPointB!.latitude, + 'lon': calibrationPointB!.longitude, + } + : null, + }; + } + + CustomMapConfig copyWith({ + String? filePath, + String? displayName, + String? mapId, + int? imageWidth, + int? imageHeight, + double? metersPerPixel, + bool clearMetersPerPixel = false, + LatLng? calibrationPointA, + bool clearCalibrationPointA = false, + LatLng? calibrationPointB, + bool clearCalibrationPointB = false, + }) { + return CustomMapConfig( + filePath: filePath ?? this.filePath, + displayName: displayName ?? this.displayName, + mapId: normalizeCustomMapId(mapId) ?? this.mapId, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + metersPerPixel: clearMetersPerPixel + ? null + : (metersPerPixel ?? this.metersPerPixel), + calibrationPointA: clearCalibrationPointA + ? null + : (calibrationPointA ?? this.calibrationPointA), + calibrationPointB: clearCalibrationPointB + ? null + : (calibrationPointB ?? this.calibrationPointB), + ); + } + + static CustomMapConfig? fromJson(Map? json) { + if (json == null) return null; + + final filePath = json['filePath']; + final displayName = json['displayName']; + final mapId = json['mapId']; + final imageWidth = json['imageWidth']; + final imageHeight = json['imageHeight']; + if (filePath is! String || + displayName is! String || + mapId is! String || + imageWidth is! int || + imageHeight is! int) { + return null; + } + + LatLng? parsePoint(dynamic raw) { + if (raw is! Map) return null; + final lat = raw['lat']; + final lon = raw['lon']; + if (lat is! num || lon is! num) return null; + return LatLng(lat.toDouble(), lon.toDouble()); + } + + final metersPerPixel = json['metersPerPixel']; + return CustomMapConfig( + filePath: filePath, + displayName: displayName, + mapId: normalizeCustomMapId(mapId) ?? mapId, + imageWidth: imageWidth, + imageHeight: imageHeight, + metersPerPixel: metersPerPixel is num ? metersPerPixel.toDouble() : null, + calibrationPointA: parsePoint(json['calibrationPointA']), + calibrationPointB: parsePoint(json['calibrationPointB']), + ); + } +} diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart new file mode 100644 index 0000000..071eb08 --- /dev/null +++ b/lib/models/device_info.dart @@ -0,0 +1,338 @@ +import 'dart:typed_data'; + +/// BLE connection state +enum ConnectionState { + disconnected, + connecting, + connected, + disconnecting, + error, +} + +/// Connection mode for app operation +enum ConnectionMode { + /// Direct BLE connection to MeshCore device (default) + ble, + + /// Direct TCP/WiFi connection to MeshCore device (port 5000) + tcp, + + /// USB serial connection (Android OTG or Web Serial API) + usb, +} + +extension ConnectionModeExtension on ConnectionMode { + String get displayName { + switch (this) { + case ConnectionMode.ble: + return 'Direct (BLE)'; + case ConnectionMode.tcp: + return 'Direct (WiFi)'; + case ConnectionMode.usb: + return 'Direct (Serial)'; + } + } + + String get description { + switch (this) { + case ConnectionMode.ble: + return 'Direct BLE connection to MeshCore device'; + case ConnectionMode.tcp: + return 'Direct WiFi/TCP connection to MeshCore device'; + case ConnectionMode.usb: + return 'Direct serial connection to MeshCore device'; + } + } +} + +/// MeshCore device information +class DeviceInfo { + final String? deviceId; + final String? deviceName; + final ConnectionState connectionState; + final int? batteryMilliVolts; + final double? batteryPercentage; + final int? storageUsedKb; + final int? storageTotalKb; + final int? signalRssi; + final double? signalSnr; + final DateTime? lastUpdate; + + // Self info from MeshCore device + final int? deviceType; + final int? txPower; + final int? maxTxPower; + final Uint8List? publicKey; + final int? advLat; + final int? advLon; + final bool? manualAddContacts; + final bool? autoAddUsers; + final bool? autoAddRepeaters; + final bool? autoAddRoomServers; + final bool? autoAddSensors; + final bool? autoAddOverwriteOldest; + final int? autoAddMaxHops; + final int? radioFreq; + final int? radioBw; + final int? radioSf; + final int? radioCr; + final String? selfName; + + // Additional device capabilities (from RESP_CODE_DEVICE_INFO) + final int? maxContacts; // Max contacts device supports + final int? maxChannels; // Max channels device supports + final int? + telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location) + final int? blePin; // BLE PIN code + final int? multiAcks; // Extra ACK mode (0=no, 1=yes) + final int? + advertLocPolicy; // Location sharing policy (0=don't share, 1=share) + + // Firmware info + final int? firmwareVersion; + final String? firmwareBuildDate; + final String? manufacturerModel; + final String? semanticVersion; + + // Repeat mode (firmware v9+) + final bool? clientRepeat; + final int? pathHashMode; + final bool? supportsSpectrumScan; + final int? spectrumScanMinKhz; + final int? spectrumScanMaxKhz; + final List<({int lower, int upper})>? allowedRepeatFreqRanges; + + DeviceInfo({ + this.deviceId, + this.deviceName, + this.connectionState = ConnectionState.disconnected, + this.batteryMilliVolts, + this.batteryPercentage, + this.storageUsedKb, + this.storageTotalKb, + this.signalRssi, + this.signalSnr, + this.lastUpdate, + this.deviceType, + this.txPower, + this.maxTxPower, + this.publicKey, + this.advLat, + this.advLon, + this.manualAddContacts, + this.autoAddUsers, + this.autoAddRepeaters, + this.autoAddRoomServers, + this.autoAddSensors, + this.autoAddOverwriteOldest, + this.autoAddMaxHops, + this.radioFreq, + this.radioBw, + this.radioSf, + this.radioCr, + this.selfName, + this.maxContacts, + this.maxChannels, + this.telemetryModes, + this.blePin, + this.multiAcks, + this.advertLocPolicy, + this.firmwareVersion, + this.firmwareBuildDate, + this.manufacturerModel, + this.semanticVersion, + this.clientRepeat, + this.pathHashMode, + this.supportsSpectrumScan, + this.spectrumScanMinKhz, + this.spectrumScanMaxKhz, + this.allowedRepeatFreqRanges, + }); + + /// Check if device is connected + bool get isConnected => connectionState == ConnectionState.connected; + + /// Check if device is connecting + bool get isConnecting => connectionState == ConnectionState.connecting; + + /// Check if device has error + bool get hasError => connectionState == ConnectionState.error; + + /// Get battery percentage (calculated or provided) + double? get batteryPercent { + if (batteryPercentage != null) return batteryPercentage!; + if (batteryMilliVolts == null) return null; + + // Rough conversion from mV to percentage (3.0V = 0%, 4.2V = 100%) + final voltage = batteryMilliVolts! / 1000.0; + if (voltage <= 3.0) return 0.0; + if (voltage >= 4.2) return 100.0; + return ((voltage - 3.0) / 1.2) * 100.0; + } + + /// Get battery status + String get batteryStatus { + final percent = batteryPercent; + if (percent == null) return 'Unknown'; + if (percent > 80) return 'Excellent'; + if (percent > 50) return 'Good'; + if (percent > 20) return 'Low'; + return 'Critical'; + } + + /// Get storage usage percentage (0-100) + double? get storageUsedPercent { + if (storageUsedKb == null || + storageTotalKb == null || + storageTotalKb == 0) { + return null; + } + return (storageUsedKb! / storageTotalKb!) * 100.0; + } + + /// Get storage available in KB + int? get storageAvailableKb { + if (storageUsedKb == null || storageTotalKb == null) { + return null; + } + return storageTotalKb! - storageUsedKb!; + } + + /// Get human-readable storage status + String get storageStatus { + final percent = storageUsedPercent; + if (percent == null) return 'Unknown'; + if (percent < 50) return 'Plenty Available'; + if (percent < 80) return 'Moderate Usage'; + if (percent < 95) return 'Low Space'; + return 'Critical - Nearly Full'; + } + + /// Get signal strength category + String get signalStrength { + if (signalRssi == null) return 'Unknown'; + if (signalRssi! > -60) return 'Excellent'; + if (signalRssi! > -70) return 'Good'; + if (signalRssi! > -80) return 'Fair'; + return 'Poor'; + } + + /// Get public key as hex string (short) + String? get publicKeyShort { + if (publicKey == null || publicKey!.length < 8) return null; + return publicKey! + .sublist(0, 8) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + } + + /// Get display name with "MeshCore-" prefix removed + String? get displayName { + if (deviceName == null) return null; + if (deviceName!.startsWith('MeshCore-')) { + return deviceName!.substring(9); // Remove "MeshCore-" (9 characters) + } + return deviceName; + } + + DeviceInfo copyWith({ + String? deviceId, + String? deviceName, + ConnectionState? connectionState, + int? batteryMilliVolts, + double? batteryPercentage, + int? storageUsedKb, + int? storageTotalKb, + int? signalRssi, + double? signalSnr, + DateTime? lastUpdate, + int? deviceType, + int? txPower, + int? maxTxPower, + Uint8List? publicKey, + int? advLat, + int? advLon, + bool? manualAddContacts, + bool? autoAddUsers, + bool? autoAddRepeaters, + bool? autoAddRoomServers, + bool? autoAddSensors, + bool? autoAddOverwriteOldest, + int? autoAddMaxHops, + int? radioFreq, + int? radioBw, + int? radioSf, + int? radioCr, + String? selfName, + int? maxContacts, + int? maxChannels, + int? telemetryModes, + int? blePin, + int? multiAcks, + int? advertLocPolicy, + int? firmwareVersion, + String? firmwareBuildDate, + String? manufacturerModel, + String? semanticVersion, + bool? clientRepeat, + int? pathHashMode, + bool? supportsSpectrumScan, + int? spectrumScanMinKhz, + int? spectrumScanMaxKhz, + List<({int lower, int upper})>? allowedRepeatFreqRanges, + }) { + return DeviceInfo( + deviceId: deviceId ?? this.deviceId, + deviceName: deviceName ?? this.deviceName, + connectionState: connectionState ?? this.connectionState, + batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts, + batteryPercentage: batteryPercentage ?? this.batteryPercentage, + storageUsedKb: storageUsedKb ?? this.storageUsedKb, + storageTotalKb: storageTotalKb ?? this.storageTotalKb, + signalRssi: signalRssi ?? this.signalRssi, + signalSnr: signalSnr ?? this.signalSnr, + lastUpdate: lastUpdate ?? this.lastUpdate, + deviceType: deviceType ?? this.deviceType, + txPower: txPower ?? this.txPower, + maxTxPower: maxTxPower ?? this.maxTxPower, + publicKey: publicKey ?? this.publicKey, + advLat: advLat ?? this.advLat, + advLon: advLon ?? this.advLon, + manualAddContacts: manualAddContacts ?? this.manualAddContacts, + autoAddUsers: autoAddUsers ?? this.autoAddUsers, + autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters, + autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers, + autoAddSensors: autoAddSensors ?? this.autoAddSensors, + autoAddOverwriteOldest: + autoAddOverwriteOldest ?? this.autoAddOverwriteOldest, + autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops, + radioFreq: radioFreq ?? this.radioFreq, + radioBw: radioBw ?? this.radioBw, + radioSf: radioSf ?? this.radioSf, + radioCr: radioCr ?? this.radioCr, + selfName: selfName ?? this.selfName, + maxContacts: maxContacts ?? this.maxContacts, + maxChannels: maxChannels ?? this.maxChannels, + telemetryModes: telemetryModes ?? this.telemetryModes, + blePin: blePin ?? this.blePin, + multiAcks: multiAcks ?? this.multiAcks, + advertLocPolicy: advertLocPolicy ?? this.advertLocPolicy, + firmwareVersion: firmwareVersion ?? this.firmwareVersion, + firmwareBuildDate: firmwareBuildDate ?? this.firmwareBuildDate, + 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, + allowedRepeatFreqRanges: + allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges, + ); + } + + @override + String toString() { + return 'DeviceInfo(name: $deviceName, state: $connectionState, battery: ${batteryPercent?.toStringAsFixed(0)}%, signal: $signalRssi dBm)'; + } +} diff --git a/lib/models/location_trail.dart b/lib/models/location_trail.dart new file mode 100644 index 0000000..66432f2 --- /dev/null +++ b/lib/models/location_trail.dart @@ -0,0 +1,106 @@ +import 'package:latlong2/latlong.dart'; + +/// Represents a single point in a location trail +class TrailPoint { + final LatLng position; + final DateTime timestamp; + final double? accuracy; + final double? speed; + + TrailPoint({ + required this.position, + required this.timestamp, + this.accuracy, + this.speed, + }); + + Map toJson() => { + 'lat': position.latitude, + 'lon': position.longitude, + 'timestamp': timestamp.toIso8601String(), + 'accuracy': accuracy, + 'speed': speed, + }; + + factory TrailPoint.fromJson(Map json) { + return TrailPoint( + position: LatLng(json['lat'] as double, json['lon'] as double), + timestamp: DateTime.parse(json['timestamp'] as String), + accuracy: json['accuracy'] as double?, + speed: json['speed'] as double?, + ); + } +} + +/// Represents a location trail (breadcrumb trail) on the map +class LocationTrail { + final String id; + final List points; + final DateTime startTime; + DateTime? endTime; + bool isActive; + + LocationTrail({ + required this.id, + List? points, + DateTime? startTime, + this.endTime, + this.isActive = true, + }) : points = points ?? [], + startTime = startTime ?? DateTime.now(); + + /// Add a new point to the trail + void addPoint(TrailPoint point) { + points.add(point); + } + + /// Get total distance traveled in meters + double get totalDistance { + if (points.length < 2) return 0; + + final distance = Distance(); + double total = 0; + + for (int i = 0; i < points.length - 1; i++) { + total += distance.as( + LengthUnit.Meter, + points[i].position, + points[i + 1].position, + ); + } + + return total; + } + + /// Get duration of the trail + Duration get duration { + if (points.isEmpty) return Duration.zero; + final end = endTime ?? DateTime.now(); + return end.difference(startTime); + } + + /// Get list of LatLng points for rendering + List get latLngPoints => points.map((p) => p.position).toList(); + + Map toJson() => { + 'id': id, + 'points': points.map((p) => p.toJson()).toList(), + 'startTime': startTime.toIso8601String(), + 'endTime': endTime?.toIso8601String(), + 'isActive': isActive, + }; + + factory LocationTrail.fromJson(Map json) { + return LocationTrail( + id: json['id'] as String, + points: (json['points'] as List) + .map((p) => TrailPoint.fromJson(p as Map)) + .toList(), + startTime: DateTime.parse(json['startTime'] as String), + endTime: json['endTime'] != null + ? DateTime.parse(json['endTime'] as String) + : null, + isActive: json['isActive'] as bool? ?? true, + ); + } +} diff --git a/lib/models/map_coordinate_space.dart b/lib/models/map_coordinate_space.dart new file mode 100644 index 0000000..5ba1ad9 --- /dev/null +++ b/lib/models/map_coordinate_space.dart @@ -0,0 +1,11 @@ +enum MapCoordinateSpace { + geo, + customMap; + + static MapCoordinateSpace fromName(String? value) { + return MapCoordinateSpace.values.firstWhere( + (space) => space.name == value, + orElse: () => MapCoordinateSpace.geo, + ); + } +} diff --git a/lib/models/map_drawing.dart b/lib/models/map_drawing.dart new file mode 100644 index 0000000..419c88d --- /dev/null +++ b/lib/models/map_drawing.dart @@ -0,0 +1,495 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import 'map_coordinate_space.dart'; +import '../utils/custom_map_id.dart'; + +/// Drawing shape type +enum DrawingShapeType { line, rectangle } + +/// Drawing color enum for compact network transmission +enum DrawingColor { red, blue, green, yellow, orange, purple, pink, cyan } + +/// Drawing colors available for user selection +class DrawingColors { + static const List palette = [ + Colors.red, + Colors.blue, + Colors.green, + Colors.yellow, + Colors.orange, + Colors.purple, + Colors.pink, + Colors.cyan, + ]; + + static String colorToName(Color color) { + if (color == Colors.red) return 'Red'; + if (color == Colors.blue) return 'Blue'; + if (color == Colors.green) return 'Green'; + if (color == Colors.yellow) return 'Yellow'; + if (color == Colors.orange) return 'Orange'; + if (color == Colors.purple) return 'Purple'; + if (color == Colors.pink) return 'Pink'; + if (color == Colors.cyan) return 'Cyan'; + return 'Unknown'; + } + + static int colorToIndex(Color color) { + for (int i = 0; i < palette.length; i++) { + if (palette[i].toARGB32() == color.toARGB32()) { + return i; + } + } + return 0; + } + + static Color indexToColor(int index) { + if (index >= 0 && index < palette.length) { + return palette[index]; + } + return palette[0]; + } +} + +abstract class MapDrawing { + final String id; + final DrawingShapeType type; + final Color color; + final DateTime createdAt; + final String? senderName; + final bool isReceived; + final String? messageId; + final bool isShared; + final bool isSent; + final bool isHidden; + final MapCoordinateSpace coordinateSpace; + final String? mapId; + + MapDrawing({ + required this.id, + required this.type, + required this.color, + required this.createdAt, + this.senderName, + this.isReceived = false, + this.messageId, + this.isShared = false, + this.isSent = false, + this.isHidden = false, + this.coordinateSpace = MapCoordinateSpace.geo, + this.mapId, + }); + + bool get isCustomMap => coordinateSpace == MapCoordinateSpace.customMap; + + Map toJson(); + + Map toNetworkJson(); + + static MapDrawing? fromNetworkJson( + Map json, { + String? senderName, + String? messageId, + MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo, + String? mapId, + }) { + final typeNum = json['t'] as int?; + if (typeNum == null || + typeNum < 0 || + typeNum >= DrawingShapeType.values.length) { + return null; + } + + try { + final type = DrawingShapeType.values[typeNum]; + switch (type) { + case DrawingShapeType.line: + return LineDrawing.fromNetworkJson( + json, + senderName: senderName, + messageId: messageId, + coordinateSpace: coordinateSpace, + mapId: mapId, + ); + case DrawingShapeType.rectangle: + return RectangleDrawing.fromNetworkJson( + json, + senderName: senderName, + messageId: messageId, + coordinateSpace: coordinateSpace, + mapId: mapId, + ); + } + } catch (_) { + return null; + } + } + + static MapDrawing? fromJson(Map json) { + final typeStr = json['type'] as String?; + if (typeStr == null) return null; + + try { + final type = DrawingShapeType.values.firstWhere( + (value) => value.name == typeStr, + ); + switch (type) { + case DrawingShapeType.line: + return LineDrawing.fromJson(json); + case DrawingShapeType.rectangle: + return RectangleDrawing.fromJson(json); + } + } catch (_) { + return null; + } + } + + LatLng getCenter(); + + LatLngBounds getBounds(); +} + +class LineDrawing extends MapDrawing { + final List points; + + LineDrawing({ + required super.id, + required super.color, + required super.createdAt, + required this.points, + super.senderName, + super.isReceived, + super.messageId, + super.isShared, + super.isSent, + super.isHidden, + super.coordinateSpace, + super.mapId, + }) : super(type: DrawingShapeType.line); + + @override + Map toJson() { + return { + 'id': id, + 'type': type.name, + 'color': color.toARGB32(), + 'createdAt': createdAt.toIso8601String(), + 'points': points + .map((point) => {'lat': point.latitude, 'lon': point.longitude}) + .toList(), + 'isShared': isShared, + 'coordinateSpace': coordinateSpace.name, + 'mapId': normalizeCustomMapId(mapId), + }; + } + + @override + Map toNetworkJson() { + final payload = { + 't': type.index, + 'c': DrawingColors.colorToIndex(color), + }; + + if (coordinateSpace == MapCoordinateSpace.customMap) { + payload['m'] = normalizeCustomMapId(mapId); + payload['p'] = points + .expand((point) => [point.latitude.round(), point.longitude.round()]) + .toList(); + return payload; + } + + payload['p'] = points + .expand( + (point) => [ + double.parse(point.latitude.toStringAsFixed(5)), + double.parse(point.longitude.toStringAsFixed(5)), + ], + ) + .toList(); + return payload; + } + + static LineDrawing fromJson(Map json) { + final pointsJson = json['points'] as List; + final points = pointsJson + .map( + (point) => LatLng( + (point['lat'] as num).toDouble(), + (point['lon'] as num).toDouble(), + ), + ) + .toList(); + final senderName = json['sender'] as String?; + + return LineDrawing( + id: json['id'] as String, + color: Color(json['color'] as int), + createdAt: DateTime.parse(json['createdAt'] as String), + points: points, + senderName: senderName, + isReceived: senderName != null, + isShared: json['isShared'] as bool? ?? false, + coordinateSpace: MapCoordinateSpace.fromName( + json['coordinateSpace'] as String?, + ), + mapId: normalizeCustomMapId(json['mapId'] as String?), + ); + } + + static LineDrawing fromNetworkJson( + Map json, { + String? senderName, + String? messageId, + MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo, + String? mapId, + }) { + final flatPoints = (json['p'] as List).cast(); + final points = []; + for (int i = 0; i < flatPoints.length; i += 2) { + points.add( + LatLng(flatPoints[i].toDouble(), flatPoints[i + 1].toDouble()), + ); + } + + return LineDrawing( + id: DateTime.now().millisecondsSinceEpoch.toString(), + color: DrawingColors.indexToColor(json['c'] as int), + createdAt: DateTime.now(), + points: points, + senderName: senderName, + isReceived: true, + messageId: messageId, + coordinateSpace: coordinateSpace, + mapId: normalizeCustomMapId(mapId), + ); + } + + LineDrawing copyWith({ + List? points, + bool? isHidden, + bool? isShared, + bool? isReceived, + String? messageId, + String? senderName, + MapCoordinateSpace? coordinateSpace, + String? mapId, + }) { + return LineDrawing( + id: id, + color: color, + createdAt: createdAt, + points: points ?? this.points, + isHidden: isHidden ?? this.isHidden, + isShared: isShared ?? this.isShared, + isReceived: isReceived ?? this.isReceived, + messageId: messageId ?? this.messageId, + senderName: senderName ?? this.senderName, + coordinateSpace: coordinateSpace ?? this.coordinateSpace, + mapId: mapId ?? this.mapId, + ); + } + + @override + LatLng getCenter() { + if (points.isEmpty) return const LatLng(0, 0); + if (points.length == 1) return points[0]; + + double sumLat = 0; + double sumLon = 0; + for (final point in points) { + sumLat += point.latitude; + sumLon += point.longitude; + } + return LatLng(sumLat / points.length, sumLon / points.length); + } + + @override + LatLngBounds getBounds() { + if (points.isEmpty) { + return LatLngBounds(const LatLng(0, 0), const LatLng(0, 0)); + } + if (points.length == 1) { + return LatLngBounds(points[0], points[0]); + } + + double minLat = points[0].latitude; + double maxLat = points[0].latitude; + double minLon = points[0].longitude; + double maxLon = points[0].longitude; + + for (final point in points) { + if (point.latitude < minLat) minLat = point.latitude; + if (point.latitude > maxLat) maxLat = point.latitude; + if (point.longitude < minLon) minLon = point.longitude; + if (point.longitude > maxLon) maxLon = point.longitude; + } + + return LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon)); + } +} + +class RectangleDrawing extends MapDrawing { + final LatLng topLeft; + final LatLng bottomRight; + + RectangleDrawing({ + required super.id, + required super.color, + required super.createdAt, + required this.topLeft, + required this.bottomRight, + super.senderName, + super.isReceived, + super.messageId, + super.isShared, + super.isSent, + super.isHidden, + super.coordinateSpace, + super.mapId, + }) : super(type: DrawingShapeType.rectangle); + + List get corners => [ + topLeft, + LatLng(topLeft.latitude, bottomRight.longitude), + bottomRight, + LatLng(bottomRight.latitude, topLeft.longitude), + topLeft, + ]; + + @override + Map toJson() { + return { + 'id': id, + 'type': type.name, + 'color': color.toARGB32(), + 'createdAt': createdAt.toIso8601String(), + 'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude}, + 'bottomRight': { + 'lat': bottomRight.latitude, + 'lon': bottomRight.longitude, + }, + 'isShared': isShared, + 'coordinateSpace': coordinateSpace.name, + 'mapId': normalizeCustomMapId(mapId), + }; + } + + @override + Map toNetworkJson() { + final payload = { + 't': type.index, + 'c': DrawingColors.colorToIndex(color), + }; + + if (coordinateSpace == MapCoordinateSpace.customMap) { + payload['m'] = normalizeCustomMapId(mapId); + payload['b'] = [ + topLeft.latitude.round(), + topLeft.longitude.round(), + bottomRight.latitude.round(), + bottomRight.longitude.round(), + ]; + return payload; + } + + payload['b'] = [ + double.parse(topLeft.latitude.toStringAsFixed(5)), + double.parse(topLeft.longitude.toStringAsFixed(5)), + double.parse(bottomRight.latitude.toStringAsFixed(5)), + double.parse(bottomRight.longitude.toStringAsFixed(5)), + ]; + return payload; + } + + static RectangleDrawing fromJson(Map json) { + final topLeftJson = json['topLeft'] as Map; + final bottomRightJson = json['bottomRight'] as Map; + final senderName = json['sender'] as String?; + + return RectangleDrawing( + id: json['id'] as String, + color: Color(json['color'] as int), + createdAt: DateTime.parse(json['createdAt'] as String), + topLeft: LatLng( + (topLeftJson['lat'] as num).toDouble(), + (topLeftJson['lon'] as num).toDouble(), + ), + bottomRight: LatLng( + (bottomRightJson['lat'] as num).toDouble(), + (bottomRightJson['lon'] as num).toDouble(), + ), + senderName: senderName, + isReceived: senderName != null, + isShared: json['isShared'] as bool? ?? false, + coordinateSpace: MapCoordinateSpace.fromName( + json['coordinateSpace'] as String?, + ), + mapId: normalizeCustomMapId(json['mapId'] as String?), + ); + } + + static RectangleDrawing fromNetworkJson( + Map json, { + String? senderName, + String? messageId, + MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo, + String? mapId, + }) { + final bounds = (json['b'] as List).cast(); + + return RectangleDrawing( + id: DateTime.now().millisecondsSinceEpoch.toString(), + color: DrawingColors.indexToColor(json['c'] as int), + createdAt: DateTime.now(), + topLeft: LatLng(bounds[0].toDouble(), bounds[1].toDouble()), + bottomRight: LatLng(bounds[2].toDouble(), bounds[3].toDouble()), + senderName: senderName, + isReceived: true, + messageId: messageId, + coordinateSpace: coordinateSpace, + mapId: normalizeCustomMapId(mapId), + ); + } + + RectangleDrawing copyWith({ + LatLng? topLeft, + LatLng? bottomRight, + bool? isHidden, + bool? isShared, + bool? isReceived, + String? messageId, + String? senderName, + MapCoordinateSpace? coordinateSpace, + String? mapId, + }) { + return RectangleDrawing( + id: id, + color: color, + createdAt: createdAt, + topLeft: topLeft ?? this.topLeft, + bottomRight: bottomRight ?? this.bottomRight, + isHidden: isHidden ?? this.isHidden, + isShared: isShared ?? this.isShared, + isReceived: isReceived ?? this.isReceived, + messageId: messageId ?? this.messageId, + senderName: senderName ?? this.senderName, + coordinateSpace: coordinateSpace ?? this.coordinateSpace, + mapId: mapId ?? this.mapId, + ); + } + + @override + LatLng getCenter() { + return LatLng( + (topLeft.latitude + bottomRight.latitude) / 2, + (topLeft.longitude + bottomRight.longitude) / 2, + ); + } + + @override + LatLngBounds getBounds() { + return LatLngBounds(topLeft, bottomRight); + } +} diff --git a/lib/models/map_layer.dart b/lib/models/map_layer.dart new file mode 100644 index 0000000..7ff0d38 --- /dev/null +++ b/lib/models/map_layer.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import '../l10n/app_localizations.dart'; + +enum MapLayerType { + openStreetMap, + openTopoMap, + esriWorldImagery, + googleHybrid, + googleRoadmap, + googleTerrain, + // Kept for stored preference compatibility after MBTiles removal. + vectorMbtiles, + wmsBase, + // Appended last: stored layer preference is the enum index. + mapyOutdoor, +} + +class MapLayer { + final MapLayerType type; + final String name; + final String urlTemplate; + final String attribution; + final double maxZoom; + + /// Extra HTTP headers required by the tile server (e.g. Referer). + final Map? headers; + + // WMS specific properties + final bool isWms; + final String? wmsBaseUrl; + final List? wmsLayers; + final String? wmsFormat; + final bool? wmsTransparent; + final List? wmsStyles; + final Crs? crs; + + const MapLayer({ + required this.type, + required this.name, + required this.urlTemplate, + required this.attribution, + required this.maxZoom, + this.headers, + this.isWms = false, + this.wmsBaseUrl, + this.wmsLayers, + this.wmsFormat, + this.wmsTransparent, + this.wmsStyles, + this.crs, + }); + + /// Get localized name for the layer + String getLocalizedName(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + switch (type) { + case MapLayerType.openStreetMap: + return localizations.openStreetMap; + case MapLayerType.openTopoMap: + return localizations.openTopoMap; + case MapLayerType.esriWorldImagery: + return localizations.esriSatellite; + case MapLayerType.googleHybrid: + return localizations.googleHybrid; + case MapLayerType.googleRoadmap: + return localizations.googleRoadmap; + case MapLayerType.googleTerrain: + return localizations.googleTerrain; + case MapLayerType.vectorMbtiles: + // Legacy value kept only for preference migration compatibility. + return name; + case MapLayerType.mapyOutdoor: + // Brand name, not translated. + return name; + case MapLayerType.wmsBase: + // For WMS layers, use the name (will be localized separately) + return name; + } + } + + static const openStreetMap = MapLayer( + type: MapLayerType.openStreetMap, + name: 'OpenStreetMap', + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap contributors', + maxZoom: 19, // OSM standard maximum + ); + + static const openTopoMap = MapLayer( + type: MapLayerType.openTopoMap, + name: 'OpenTopoMap', + urlTemplate: 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png', + attribution: '© OpenTopoMap (CC-BY-SA)', + maxZoom: 17.49, // OpenTopoMap maximum (just below level 18) + ); + + /// Mapy.cz outdoor map: OSM data with LIDAR-based shaded relief. + /// The tile server rejects requests without a mapy.com Referer. + static const mapyOutdoor = MapLayer( + type: MapLayerType.mapyOutdoor, + name: 'Mapy.cz Outdoor (OSM + LIDAR)', + urlTemplate: 'https://mapserver.mapy.cz/turist-en/retina/{z}-{x}-{y}', + attribution: '© Seznam.cz, a.s., © OpenStreetMap contributors', + maxZoom: 19, + headers: {'Referer': 'https://mapy.com/'}, + ); + + static const esriWorldImagery = MapLayer( + type: MapLayerType.esriWorldImagery, + name: 'ESRI Satellite', + urlTemplate: + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + attribution: '© Esri', + maxZoom: 19, // ESRI World Imagery maximum + ); + + static const googleHybrid = MapLayer( + type: MapLayerType.googleHybrid, + name: 'Google Hybrid', + urlTemplate: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}', + attribution: '© Google', + maxZoom: 20, // Google Maps maximum + ); + + static const googleRoadmap = MapLayer( + type: MapLayerType.googleRoadmap, + name: 'Google Roadmap', + urlTemplate: 'http://mt0.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}', + attribution: '© Google', + maxZoom: 20, // Google Maps maximum + ); + + static const googleTerrain = MapLayer( + type: MapLayerType.googleTerrain, + name: 'Google Terrain', + urlTemplate: 'http://mt0.google.com/vt/lyrs=p&hl=en&x={x}&y={y}&z={z}', + attribution: '© Google', + maxZoom: 20, // Google Maps maximum + ); + + /// Slovenian Aerial Imagery 2024 (Ortofoto) - WMS Base Layer + /// Uses EPSG:3794 coordinate system (GeoWebCache tile matrix zoom 0-15) + /// Note: CRS is initialized at runtime in getSlovenianAerial2024() + static MapLayer getSlovenianAerial2024(Crs slovenianCrs) { + return MapLayer( + type: MapLayerType.wmsBase, + name: 'Ortofoto 2024 (Slovenija)', + urlTemplate: '', // Not used for WMS + attribution: '© GURS (Geodetska uprava Republike Slovenije)', + maxZoom: 15, // GeoWebCache tile matrix maximum + isWms: true, + wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + wmsLayers: const ['pregledovalnik:DOF_2024'], + wmsFormat: 'image/jpeg', + wmsTransparent: false, + crs: slovenianCrs, + ); + } + + /// Slovenian Topographic Map 1:25000 (DTK25) - WMS Base Layer + /// Uses EPSG:3794 coordinate system (GeoWebCache tile matrix zoom 0-15) + /// Note: CRS is initialized at runtime in getDTK25() + static MapLayer getDTK25(Crs slovenianCrs) { + return MapLayer( + type: MapLayerType.wmsBase, + name: 'DTK25 (Slovenija)', + urlTemplate: '', // Not used for WMS + attribution: '© GURS (Geodetska uprava Republike Slovenije)', + maxZoom: 15, // GeoWebCache tile matrix maximum + isWms: true, + wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + wmsLayers: const ['pregledovalnik:DTK25'], + wmsFormat: 'image/jpeg', + wmsTransparent: false, + crs: slovenianCrs, + ); + } + + static const List allLayers = [ + openStreetMap, + openTopoMap, + mapyOutdoor, + esriWorldImagery, + googleHybrid, + googleRoadmap, + googleTerrain, + // Note: Slovenian aerial layer is added dynamically via getSlovenianAerial2024() + ]; + + static MapLayer fromType(MapLayerType type) { + return allLayers.firstWhere((layer) => layer.type == type); + } +} diff --git a/lib/models/message.dart b/lib/models/message.dart new file mode 100644 index 0000000..012c2b9 --- /dev/null +++ b/lib/models/message.dart @@ -0,0 +1,83 @@ +export 'package:meshcore_client/meshcore_client.dart' + show + Message, + MessageType, + MessageTextType, + MessageDeliveryStatus, + MessageRecipient; + +import 'package:flutter/foundation.dart'; +import 'package:meshcore_client/meshcore_client.dart'; +import 'sar_marker.dart'; +import 'map_coordinate_space.dart'; +import '../utils/voice_message_parser.dart'; + +extension MessageVoiceExtension on Message { + /// True when this message is a voice recording (`V:` text or binary voice). + bool get isVoiceMessage => isVoice; + + /// Parses the voice packet mode from the stored [voiceId] session info. + /// Returns null for non-voice messages. + VoicePacketMode? get voicePacketMode { + if (!isVoice || text.isEmpty) return null; + final envelope = VoiceEnvelope.tryParseText(text); + if (envelope != null) return envelope.mode; + final pkt = VoicePacket.tryParseText(text); + return pkt?.mode; + } +} + +extension MessageSarExtension on Message { + /// Infer the [SarMarkerType] from stored SAR fields. + /// Returns null if this is not a SAR marker message. + SarMarkerType? get sarMarkerType { + if (!isSarMarker) return null; + + if (sarCustomEmoji != null && sarCustomEmoji!.isNotEmpty) { + return SarMarkerType.fromEmoji(sarCustomEmoji!); + } + + final trimmed = text.trim(); + if (!trimmed.startsWith('S:')) return null; + + final parts = trimmed.split(':'); + if (parts.length < 3) return null; + + return SarMarkerType.fromEmoji(parts[1]); + } + + /// Convert to a [SarMarker] if this message contains SAR data. + SarMarker? toSarMarker() { + if (!isSarMarker || sarMarkerType == null) { + return null; + } + + debugPrint('📍 [Message.toSarMarker] Converting to marker:'); + debugPrint(' message.text: "$text"'); + debugPrint(' message.sarNotes: "$sarNotes"'); + debugPrint(' message.sarMarkerType: $sarMarkerType'); + debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"'); + + final coordinateSpace = sarCustomMapPoint != null && sarCustomMapId != null + ? MapCoordinateSpace.customMap + : MapCoordinateSpace.geo; + final location = sarCustomMapPoint ?? sarGpsCoordinates; + if (location == null) { + return null; + } + + return SarMarker( + id: id, + type: sarMarkerType!, + location: location, + timestamp: sentAt, + senderPublicKey: senderPublicKeyPrefix, + senderName: senderName, + notes: sarNotes, + customEmoji: sarCustomEmoji, + colorIndex: sarColorIndex, + coordinateSpace: coordinateSpace, + mapId: sarCustomMapId, + ); + } +} diff --git a/lib/models/message_contact_location.dart b/lib/models/message_contact_location.dart new file mode 100644 index 0000000..7de6027 --- /dev/null +++ b/lib/models/message_contact_location.dart @@ -0,0 +1,67 @@ +import 'package:latlong2/latlong.dart'; + +class MessageContactLocation { + final LatLng location; + final String source; + final DateTime capturedAt; + final DateTime? sourceTimestamp; + + const MessageContactLocation({ + required this.location, + required this.source, + required this.capturedAt, + this.sourceTimestamp, + }); + + String get technicalSourceLabel { + switch (source) { + case 'shared': + return 'shared location'; + case 'gps': + return 'shared location'; + case 'telemetry': + return 'live telemetry'; + case 'advert': + return 'shared advert'; + default: + return source; + } + } + + String get formattedCoordinates => + '${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}'; + + Map toJson() { + return { + 'latitude': location.latitude, + 'longitude': location.longitude, + 'source': source, + 'capturedAtMillis': capturedAt.millisecondsSinceEpoch, + 'sourceTimestampMillis': sourceTimestamp?.millisecondsSinceEpoch, + }; + } + + static MessageContactLocation? fromJson(Map json) { + final latitude = json['latitude']; + final longitude = json['longitude']; + final source = json['source']; + final capturedAtMillis = json['capturedAtMillis']; + if (latitude is! num || + longitude is! num || + source is! String || + capturedAtMillis is! int) { + return null; + } + + return MessageContactLocation( + location: LatLng(latitude.toDouble(), longitude.toDouble()), + source: source, + capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis), + sourceTimestamp: json['sourceTimestampMillis'] is int + ? DateTime.fromMillisecondsSinceEpoch( + json['sourceTimestampMillis'] as int, + ) + : null, + ); + } +} diff --git a/lib/models/message_reception_details.dart b/lib/models/message_reception_details.dart new file mode 100644 index 0000000..d689058 --- /dev/null +++ b/lib/models/message_reception_details.dart @@ -0,0 +1,140 @@ +const int _transmitEstimateToleranceMs = 1500; + +int? sanitizeEstimatedTransmitMs({ + required int? estimatedTransmitMs, + required int? senderToReceiptMs, +}) { + if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) { + return null; + } + + if (senderToReceiptMs == null || senderToReceiptMs <= 0) { + return estimatedTransmitMs; + } + + // Sender timestamps are second-granularity, so allow a small cushion before + // treating the estimate as impossible for the observed delivery time. + if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) { + return null; + } + + return estimatedTransmitMs; +} + +class MessageReceptionDetails { + final DateTime capturedAt; + final DateTime? packetLoggedAt; + final int? rssiDbm; + final double? snrDb; + final List? pathBytes; + final int? senderToReceiptMs; + final int? estimatedTransmitMs; + final int? postTransmitDelayMs; + final int receivedCopies; + + const MessageReceptionDetails({ + required this.capturedAt, + this.packetLoggedAt, + this.rssiDbm, + this.snrDb, + this.pathBytes, + this.senderToReceiptMs, + this.estimatedTransmitMs, + this.postTransmitDelayMs, + this.receivedCopies = 1, + }); + + String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty + ? null + : pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + + Map toJson() { + return { + 'capturedAtMillis': capturedAt.millisecondsSinceEpoch, + 'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch, + 'rssiDbm': rssiDbm, + 'snrDb': snrDb, + 'pathBytes': pathBytes, + 'senderToReceiptMs': senderToReceiptMs, + 'estimatedTransmitMs': estimatedTransmitMs, + 'postTransmitDelayMs': postTransmitDelayMs, + 'receivedCopies': receivedCopies, + }; + } + + MessageReceptionDetails copyWith({ + DateTime? capturedAt, + DateTime? packetLoggedAt, + int? rssiDbm, + double? snrDb, + List? pathBytes, + int? senderToReceiptMs, + int? estimatedTransmitMs, + int? postTransmitDelayMs, + int? receivedCopies, + }) { + return MessageReceptionDetails( + capturedAt: capturedAt ?? this.capturedAt, + packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt, + rssiDbm: rssiDbm ?? this.rssiDbm, + snrDb: snrDb ?? this.snrDb, + pathBytes: pathBytes ?? this.pathBytes, + senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs, + estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs, + postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs, + receivedCopies: receivedCopies ?? this.receivedCopies, + ); + } + + static MessageReceptionDetails mergeDuplicate({ + MessageReceptionDetails? existing, + MessageReceptionDetails? incoming, + }) { + final base = + existing ?? + incoming ?? + MessageReceptionDetails(capturedAt: DateTime.now()); + + return base.copyWith( + capturedAt: incoming?.capturedAt ?? existing?.capturedAt, + packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt, + rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm, + snrDb: incoming?.snrDb ?? existing?.snrDb, + pathBytes: incoming?.pathBytes ?? existing?.pathBytes, + senderToReceiptMs: + incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs, + estimatedTransmitMs: + incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs, + postTransmitDelayMs: + incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs, + receivedCopies: + (existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1), + ); + } + + static MessageReceptionDetails? fromJson(Map json) { + final capturedAtMillis = json['capturedAtMillis']; + if (capturedAtMillis is! int) { + return null; + } + + final pathBytes = json['pathBytes']; + return MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis), + packetLoggedAt: json['packetLoggedAtMillis'] is int + ? DateTime.fromMillisecondsSinceEpoch( + json['packetLoggedAtMillis'] as int, + ) + : null, + rssiDbm: json['rssiDbm'] as int?, + snrDb: (json['snrDb'] as num?)?.toDouble(), + pathBytes: pathBytes is List + ? pathBytes.whereType().map((b) => b.toInt()).toList() + : null, + senderToReceiptMs: json['senderToReceiptMs'] as int?, + estimatedTransmitMs: json['estimatedTransmitMs'] as int?, + postTransmitDelayMs: json['postTransmitDelayMs'] as int?, + receivedCopies: json['receivedCopies'] as int? ?? 1, + ); + } +} diff --git a/lib/models/message_route_metadata.dart b/lib/models/message_route_metadata.dart new file mode 100644 index 0000000..c20b40c --- /dev/null +++ b/lib/models/message_route_metadata.dart @@ -0,0 +1,79 @@ +import 'path_selection.dart'; + +class MessageRouteMetadata { + final PathSelectionMode mode; + final bool routerFallbackAttempted; + final String? relayName; + final String? relayKey6; + final String? canonicalPath; + final int? hopCount; + + const MessageRouteMetadata({ + required this.mode, + required this.routerFallbackAttempted, + this.relayName, + this.relayKey6, + this.canonicalPath, + this.hopCount, + }); + + factory MessageRouteMetadata.fromSelection( + PathSelection selection, { + required bool routerFallbackAttempted, + }) { + return MessageRouteMetadata( + mode: selection.mode, + routerFallbackAttempted: routerFallbackAttempted, + relayName: selection.relayName, + relayKey6: selection.relayKey6, + canonicalPath: selection.canonicalPath.isEmpty + ? null + : selection.canonicalPath, + hopCount: selection.hopCount > 0 ? selection.hopCount : null, + ); + } + + String get modeLabel { + switch (mode) { + case PathSelectionMode.directCurrent: + return 'Current direct path'; + case PathSelectionMode.directHistorical: + return 'Rotated direct path'; + case PathSelectionMode.flood: + return 'Flood route'; + case PathSelectionMode.nearestRouter: + final suffix = relayName?.trim().isNotEmpty == true + ? ' via $relayName' + : relayKey6?.trim().isNotEmpty == true + ? ' via $relayKey6' + : ''; + return 'Nearest router$suffix'; + } + } + + Map toJson() { + return { + 'mode': mode.name, + 'router_fallback_attempted': routerFallbackAttempted, + 'relay_name': relayName, + 'relay_key6': relayKey6, + 'canonical_path': canonicalPath, + 'hop_count': hopCount, + }; + } + + factory MessageRouteMetadata.fromJson(Map json) { + return MessageRouteMetadata( + mode: PathSelectionMode.values.firstWhere( + (value) => value.name == json['mode'], + orElse: () => PathSelectionMode.directCurrent, + ), + routerFallbackAttempted: + json['router_fallback_attempted'] as bool? ?? false, + relayName: json['relay_name'] as String?, + relayKey6: json['relay_key6'] as String?, + canonicalPath: json['canonical_path'] as String?, + hopCount: json['hop_count'] as int?, + ); + } +} diff --git a/lib/models/message_transfer_details.dart b/lib/models/message_transfer_details.dart new file mode 100644 index 0000000..26659af --- /dev/null +++ b/lib/models/message_transfer_details.dart @@ -0,0 +1,142 @@ +class MessageTransferDownloader { + final String requesterKey6; + final String? requesterName; + final int transferCount; + final DateTime lastTransferredAt; + + const MessageTransferDownloader({ + required this.requesterKey6, + this.requesterName, + required this.transferCount, + required this.lastTransferredAt, + }); + + MessageTransferDownloader copyWith({ + String? requesterKey6, + String? requesterName, + int? transferCount, + DateTime? lastTransferredAt, + }) { + return MessageTransferDownloader( + requesterKey6: requesterKey6 ?? this.requesterKey6, + requesterName: requesterName ?? this.requesterName, + transferCount: transferCount ?? this.transferCount, + lastTransferredAt: lastTransferredAt ?? this.lastTransferredAt, + ); + } + + Map toJson() { + return { + 'requesterKey6': requesterKey6, + 'requesterName': requesterName, + 'transferCount': transferCount, + 'lastTransferredAtMillis': lastTransferredAt.millisecondsSinceEpoch, + }; + } + + static MessageTransferDownloader? fromJson(Map json) { + final requesterKey6 = json['requesterKey6']; + final transferCount = json['transferCount']; + final lastTransferredAtMillis = json['lastTransferredAtMillis']; + if (requesterKey6 is! String || + transferCount is! int || + lastTransferredAtMillis is! int) { + return null; + } + + return MessageTransferDownloader( + requesterKey6: requesterKey6, + requesterName: json['requesterName'] as String?, + transferCount: transferCount, + lastTransferredAt: DateTime.fromMillisecondsSinceEpoch( + lastTransferredAtMillis, + ), + ); + } +} + +class MessageTransferDetails { + final int totalTransfers; + final List downloaders; + + const MessageTransferDetails({ + required this.totalTransfers, + required this.downloaders, + }); + + const MessageTransferDetails.empty() + : totalTransfers = 0, + downloaders = const []; + + MessageTransferDetails registerTransfer({ + required String requesterKey6, + String? requesterName, + DateTime? transferredAt, + }) { + final eventAt = transferredAt ?? DateTime.now(); + final normalizedName = requesterName?.trim(); + final updatedDownloaders = List.from( + downloaders, + ); + final index = updatedDownloaders.indexWhere( + (entry) => entry.requesterKey6 == requesterKey6, + ); + + if (index == -1) { + updatedDownloaders.add( + MessageTransferDownloader( + requesterKey6: requesterKey6, + requesterName: normalizedName?.isEmpty ?? true + ? null + : normalizedName, + transferCount: 1, + lastTransferredAt: eventAt, + ), + ); + } else { + final existing = updatedDownloaders[index]; + updatedDownloaders[index] = existing.copyWith( + requesterName: normalizedName?.isEmpty ?? true + ? existing.requesterName + : normalizedName, + transferCount: existing.transferCount + 1, + lastTransferredAt: eventAt, + ); + } + + updatedDownloaders.sort( + (a, b) => b.lastTransferredAt.compareTo(a.lastTransferredAt), + ); + + return MessageTransferDetails( + totalTransfers: totalTransfers + 1, + downloaders: updatedDownloaders, + ); + } + + Map toJson() { + return { + 'totalTransfers': totalTransfers, + 'downloaders': downloaders.map((entry) => entry.toJson()).toList(), + }; + } + + static MessageTransferDetails? fromJson(Map json) { + final totalTransfers = json['totalTransfers']; + if (totalTransfers is! int) { + return null; + } + + final rawDownloaders = json['downloaders'] as List? ?? const []; + final downloaders = rawDownloaders + .whereType>() + .map(MessageTransferDownloader.fromJson) + .whereType() + .toList(); + + return MessageTransferDetails( + totalTransfers: totalTransfers, + downloaders: downloaders, + ); + } +} diff --git a/lib/models/path_selection.dart b/lib/models/path_selection.dart new file mode 100644 index 0000000..82a4758 --- /dev/null +++ b/lib/models/path_selection.dart @@ -0,0 +1,65 @@ +import 'dart:typed_data'; + +enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter } + +class PathSelection { + final PathSelectionMode mode; + final Uint8List pathBytes; + final int hopCount; + final int hashSize; + final String? relayName; + final String? relayKey6; + + const PathSelection({ + required this.mode, + required this.pathBytes, + required this.hopCount, + required this.hashSize, + this.relayName, + this.relayKey6, + }); + + PathSelection.flood() + : mode = PathSelectionMode.flood, + pathBytes = Uint8List(0), + hopCount = -1, + hashSize = 1, + relayName = null, + relayKey6 = null; + + bool get usesFlood => mode == PathSelectionMode.flood; + bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0; + + String get canonicalPath { + if (!hasDirectPath) return ''; + final hops = []; + for (var index = 0; index < pathBytes.length; index += hashSize) { + hops.add( + pathBytes + .sublist(index, index + hashSize) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(), + ); + } + return hops.join(','); + } + + PathSelection copyWith({ + PathSelectionMode? mode, + Uint8List? pathBytes, + int? hopCount, + int? hashSize, + String? relayName, + String? relayKey6, + }) { + return PathSelection( + mode: mode ?? this.mode, + pathBytes: pathBytes ?? this.pathBytes, + hopCount: hopCount ?? this.hopCount, + hashSize: hashSize ?? this.hashSize, + relayName: relayName ?? this.relayName, + relayKey6: relayKey6 ?? this.relayKey6, + ); + } +} diff --git a/lib/models/room_login_state.dart b/lib/models/room_login_state.dart new file mode 100644 index 0000000..0abd52e --- /dev/null +++ b/lib/models/room_login_state.dart @@ -0,0 +1,111 @@ +import 'dart:typed_data'; + +/// Represents the login state for a room +class RoomLoginState { + final Uint8List publicKeyPrefix; + final bool isLoggedIn; + final bool isAdmin; + final int permissions; + final int? tag; + final DateTime? loginTime; + final bool hasPassword; // Whether we have a saved password + + const RoomLoginState({ + required this.publicKeyPrefix, + this.isLoggedIn = false, + this.isAdmin = false, + this.permissions = 0, + this.tag, + this.loginTime, + this.hasPassword = false, + }); + + /// Create a logged-in state + factory RoomLoginState.loggedIn({ + required Uint8List publicKeyPrefix, + required int permissions, + required bool isAdmin, + required int tag, + required bool hasPassword, + }) { + return RoomLoginState( + publicKeyPrefix: publicKeyPrefix, + isLoggedIn: true, + isAdmin: isAdmin, + permissions: permissions, + tag: tag, + loginTime: DateTime.now(), + hasPassword: hasPassword, + ); + } + + /// Create a logged-out state + factory RoomLoginState.loggedOut({ + required Uint8List publicKeyPrefix, + bool hasPassword = false, + }) { + return RoomLoginState( + publicKeyPrefix: publicKeyPrefix, + isLoggedIn: false, + hasPassword: hasPassword, + ); + } + + /// Copy with modified fields + RoomLoginState copyWith({ + Uint8List? publicKeyPrefix, + bool? isLoggedIn, + bool? isAdmin, + int? permissions, + int? tag, + DateTime? loginTime, + bool? hasPassword, + }) { + return RoomLoginState( + publicKeyPrefix: publicKeyPrefix ?? this.publicKeyPrefix, + isLoggedIn: isLoggedIn ?? this.isLoggedIn, + isAdmin: isAdmin ?? this.isAdmin, + permissions: permissions ?? this.permissions, + tag: tag ?? this.tag, + loginTime: loginTime ?? this.loginTime, + hasPassword: hasPassword ?? this.hasPassword, + ); + } + + /// Get formatted public key prefix (e.g., "15:59:89:54:b4:d4") + String get publicKeyPrefixHex { + return publicKeyPrefix + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); + } + + /// Get login duration if logged in + Duration? get loginDuration { + if (!isLoggedIn || loginTime == null) return null; + return DateTime.now().difference(loginTime!); + } + + /// Get formatted login duration (e.g., "2h 15m ago") + String? get loginDurationFormatted { + final duration = loginDuration; + if (duration == null) return null; + + if (duration.inMinutes < 1) { + return 'just now'; + } else if (duration.inMinutes < 60) { + return '${duration.inMinutes}m ago'; + } else if (duration.inHours < 24) { + final hours = duration.inHours; + final minutes = duration.inMinutes % 60; + return minutes > 0 ? '${hours}h ${minutes}m ago' : '${hours}h ago'; + } else { + final days = duration.inDays; + return '${days}d ago'; + } + } + + @override + String toString() { + return 'RoomLoginState(prefix: $publicKeyPrefixHex, loggedIn: $isLoggedIn, admin: $isAdmin, hasPassword: $hasPassword)'; + } +} diff --git a/lib/models/sar_marker.dart b/lib/models/sar_marker.dart new file mode 100644 index 0000000..9302ba7 --- /dev/null +++ b/lib/models/sar_marker.dart @@ -0,0 +1,209 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import '../l10n/app_localizations.dart'; +import 'map_coordinate_space.dart'; +import '../services/sar_template_service.dart'; + +/// SAR (Search & Rescue) marker types +enum SarMarkerType { + foundPerson('🧑', 'Found Person'), + fire('🔥', 'Fire'), + stagingArea('🏕️', 'Staging Area'), + object('📦', 'Object'), + unknown('❓', 'Unknown'); + + const SarMarkerType(this.emoji, this.displayName); + final String emoji; + final String displayName; + + /// Get localized display name + String getLocalizedName(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + switch (this) { + case SarMarkerType.foundPerson: + return l10n.sarMarkerFoundPerson; + case SarMarkerType.fire: + return l10n.sarMarkerFire; + case SarMarkerType.stagingArea: + return l10n.sarMarkerStagingArea; + case SarMarkerType.object: + return l10n.sarMarkerObject; + case SarMarkerType.unknown: + return 'Unknown'; + } + } + + static SarMarkerType fromEmoji(String emoji) { + switch (emoji) { + case '🧑': + case '👤': + return SarMarkerType.foundPerson; + case '🔥': + return SarMarkerType.fire; + case '🏕️': + case '⛺': + return SarMarkerType.stagingArea; + case '📦': + return SarMarkerType.object; + default: + return SarMarkerType.unknown; + } + } + + /// Get map marker color + String get markerColor { + switch (this) { + case SarMarkerType.foundPerson: + return '#4CAF50'; // Green + case SarMarkerType.fire: + return '#F44336'; // Red + case SarMarkerType.stagingArea: + return '#2196F3'; // Blue + case SarMarkerType.object: + return '#9C27B0'; // Purple + default: + return '#9E9E9E'; // Gray + } + } +} + +/// SAR marker from special messages +class SarMarker { + final String id; + final SarMarkerType type; + final LatLng location; + final DateTime timestamp; + final Uint8List? senderPublicKey; + final String? senderName; + final String? notes; + final String? customEmoji; // For custom SAR markers not in predefined types + final int? colorIndex; // Color index (0-7) from standard palette + final MapCoordinateSpace coordinateSpace; + final String? mapId; + + SarMarker({ + required this.id, + required this.type, + required this.location, + required this.timestamp, + this.senderPublicKey, + this.senderName, + this.notes, + this.customEmoji, + this.colorIndex, + this.coordinateSpace = MapCoordinateSpace.geo, + this.mapId, + }); + + bool get isCustomMapMarker => + coordinateSpace == MapCoordinateSpace.customMap && mapId != null; + + /// Get sender public key as hex string (short) + String? get senderKeyShort { + if (senderPublicKey == null || senderPublicKey!.length < 8) return null; + return senderPublicKey! + .sublist(0, 8) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + } + + /// Get friendly time since marker was created + String get timeAgo { + final diff = DateTime.now().difference(timestamp); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } + + /// Check if marker is recent (within last hour) + bool get isRecent { + return DateTime.now().difference(timestamp).inHours < 1; + } + + /// Get the emoji to display (custom emoji if available, otherwise type emoji) + String get emoji { + return customEmoji ?? type.emoji; + } + + /// Get display name - uses notes if available, otherwise looks up template by emoji, otherwise type name + String get displayName { + if (notes != null && notes!.isNotEmpty) { + return notes!; + } + + // If no notes and we have a custom emoji, try to look up the template + if (customEmoji != null) { + // Import the service here to avoid circular dependencies + // We'll use a static lookup method + return _lookupTemplateNameByEmoji(customEmoji!) ?? type.displayName; + } + + return type.displayName; + } + + /// Look up template name by emoji from SarTemplateService + static String? _lookupTemplateNameByEmoji(String emoji) { + try { + // Use the singleton instance + final service = SarTemplateService(); + if (!service.isInitialized) { + return null; + } + + // Find template with matching emoji + final template = service.templates.firstWhere( + (t) => t.emoji == emoji, + orElse: () => throw StateError('No template found'), + ); + + return template.name; + } catch (e) { + // Template not found or service not initialized + return null; + } + } + + SarMarker copyWith({ + String? id, + SarMarkerType? type, + LatLng? location, + DateTime? timestamp, + Uint8List? senderPublicKey, + String? senderName, + String? notes, + String? customEmoji, + int? colorIndex, + MapCoordinateSpace? coordinateSpace, + String? mapId, + }) { + return SarMarker( + id: id ?? this.id, + type: type ?? this.type, + location: location ?? this.location, + timestamp: timestamp ?? this.timestamp, + senderPublicKey: senderPublicKey ?? this.senderPublicKey, + senderName: senderName ?? this.senderName, + notes: notes ?? this.notes, + customEmoji: customEmoji ?? this.customEmoji, + colorIndex: colorIndex ?? this.colorIndex, + coordinateSpace: coordinateSpace ?? this.coordinateSpace, + mapId: mapId ?? this.mapId, + ); + } + + @override + String toString() { + return 'SarMarker(type: ${type.displayName}, location: $location, sender: $senderName, time: $timeAgo)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SarMarker && id == other.id; + } + + @override + int get hashCode => id.hashCode; +} diff --git a/lib/models/sar_template.dart b/lib/models/sar_template.dart new file mode 100644 index 0000000..79ff7d7 --- /dev/null +++ b/lib/models/sar_template.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; + +/// SAR Template - Customizable template for SAR (Cursor on Target) messages +class SarTemplate { + final String id; + final String emoji; + final String name; + final String description; + final String colorHex; + final bool isDefault; + + /// Standard color palette for SAR markers (index 0-7) + /// This palette is used for transmission to ensure consistent colors across devices + static const List colorPalette = [ + '#F44336', // 0 - Red + '#2196F3', // 1 - Blue + '#4CAF50', // 2 - Green + '#FFC107', // 3 - Yellow + '#FF9800', // 4 - Orange + '#9C27B0', // 5 - Purple + '#E91E63', // 6 - Pink + '#00BCD4', // 7 - Cyan + ]; + + SarTemplate({ + required this.id, + required this.emoji, + required this.name, + required this.description, + required this.colorHex, + this.isDefault = false, + }); + + /// Get color from hex string + Color get color { + final hexCode = colorHex.replaceAll('#', ''); + return Color(int.parse('FF$hexCode', radix: 16)); + } + + /// Get localized display name for this template + /// Returns localized name for default templates, or the stored name for custom templates + String getLocalizedName(BuildContext context) { + final l10n = AppLocalizations.of(context); + if (l10n == null) return name; + + // Return localized names for default templates + switch (id) { + case 'default_found_person': + return l10n.sarMarkerFoundPerson; + case 'default_fire': + return l10n.sarMarkerFire; + case 'default_staging_area': + return l10n.sarMarkerStagingArea; + case 'default_object': + return l10n.sarMarkerObject; + default: + // For custom templates, return the stored name + return name; + } + } + + /// Get the closest color index from the standard palette + /// Returns 0-7 for standard colors, or the closest match + int getColorIndex() { + // Normalize both colors to uppercase for comparison + final normalizedColorHex = colorHex.toUpperCase(); + + // Check for exact match first + for (int i = 0; i < colorPalette.length; i++) { + if (colorPalette[i].toUpperCase() == normalizedColorHex) { + return i; + } + } + + // If no exact match, find closest color by calculating distance + // Parse RGB values + final hexCode = colorHex.replaceAll('#', ''); + final r = int.parse(hexCode.substring(0, 2), radix: 16); + final g = int.parse(hexCode.substring(2, 4), radix: 16); + final b = int.parse(hexCode.substring(4, 6), radix: 16); + + int closestIndex = 0; + double minDistance = double.infinity; + + for (int i = 0; i < colorPalette.length; i++) { + final paletteHex = colorPalette[i].replaceAll('#', ''); + final pr = int.parse(paletteHex.substring(0, 2), radix: 16); + final pg = int.parse(paletteHex.substring(2, 4), radix: 16); + final pb = int.parse(paletteHex.substring(4, 6), radix: 16); + + // Calculate Euclidean distance in RGB space + final distance = ((r - pr) * (r - pr) + (g - pg) * (g - pg) + (b - pb) * (b - pb)).toDouble(); + + if (distance < minDistance) { + minDistance = distance; + closestIndex = i; + } + } + + return closestIndex; + } + + /// Get color hex from palette index + static String getColorFromIndex(int index) { + if (index < 0 || index >= colorPalette.length) { + return '#9E9E9E'; // Gray for invalid index + } + return colorPalette[index]; + } + + /// Create from JSON + factory SarTemplate.fromJson(Map json) { + return SarTemplate( + id: json['id'] as String, + emoji: json['emoji'] as String, + name: json['name'] as String, + description: json['description'] as String? ?? '', + colorHex: json['colorHex'] as String, + isDefault: json['isDefault'] as bool? ?? false, + ); + } + + /// Convert to JSON + Map toJson() { + return { + 'id': id, + 'emoji': emoji, + 'name': name, + 'description': description, + 'colorHex': colorHex, + 'isDefault': isDefault, + }; + } + + /// Create from SAR message format (S:emoji:0,0:description) + /// Example: S:🧑:0,0:Person found + factory SarTemplate.fromSarMessage(String message) { + final trimmed = message.trim(); + if (!trimmed.startsWith('S:')) { + throw FormatException('SAR message must start with "S:"'); + } + + // Parse format: S:emoji:lat,lon:description + final parts = trimmed.split(':'); + if (parts.length < 3) { + throw FormatException('Invalid SAR message format'); + } + + final emoji = parts[1].trim(); + if (emoji.isEmpty) { + throw FormatException('Emoji cannot be empty'); + } + + // Extract description (everything after the third colon) + String description = ''; + if (parts.length > 3) { + description = parts.sublist(3).join(':').trim(); + } + + // Generate ID from emoji + description + final id = '${emoji}_${DateTime.now().millisecondsSinceEpoch}'; + + // Auto-assign color based on emoji + String colorHex = _getColorForEmoji(emoji); + + return SarTemplate( + id: id, + emoji: emoji, + name: description.isNotEmpty ? description : emoji, + description: description, + colorHex: colorHex, + isDefault: false, + ); + } + + /// Convert to SAR message format with placeholder coordinates + /// New format: S:emoji:colorIndex:0,0:description + /// Example: S:🧑:2:0,0:Person found (2 = Green) + String toSarMessage() { + final colorIndex = getColorIndex(); + if (description.isNotEmpty) { + return 'S:$emoji:$colorIndex:0,0:$description'; + } + return 'S:$emoji:$colorIndex:0,0'; + } + + /// Auto-assign color based on emoji (uses standard color palette) + static String _getColorForEmoji(String emoji) { + // Default emoji to color mapping using standard palette + final colorMap = { + // Green (index 2) - Person, Safe, Nature + '🧑': colorPalette[2], + '👤': colorPalette[2], + '✅': colorPalette[2], + '🌲': colorPalette[2], + + // Red (index 0) - Fire, Hazard, Medical, Emergency + '🔥': colorPalette[0], + '🚒': colorPalette[0], + '🚑': colorPalette[0], + '❌': colorPalette[0], + '🏥': colorPalette[0], + + // Orange (index 4) - Staging, Assembly + '🏕️': colorPalette[4], + '⛺': colorPalette[4], + + // Purple (index 5) - Objects + '📦': colorPalette[5], + + // Blue (index 1) - Water, Air support + '🚁': colorPalette[1], + '💧': colorPalette[1], + + // Yellow (index 3) - Warning, Caution + '⚠️': colorPalette[3], + }; + + return colorMap[emoji] ?? '#9E9E9E'; // Default gray for unknown emojis + } + + /// Copy with modifications + SarTemplate copyWith({ + String? id, + String? emoji, + String? name, + String? description, + String? colorHex, + bool? isDefault, + }) { + return SarTemplate( + id: id ?? this.id, + emoji: emoji ?? this.emoji, + name: name ?? this.name, + description: description ?? this.description, + colorHex: colorHex ?? this.colorHex, + isDefault: isDefault ?? this.isDefault, + ); + } + + @override + String toString() { + return 'SarTemplate(id: $id, emoji: $emoji, name: $name, description: $description)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SarTemplate && id == other.id; + } + + @override + int get hashCode => id.hashCode; + + /// Default templates (uses standard color palette) + /// Colors reference: + /// - 0 Red (#F44336) - Fire, Hazard, Medical + /// - 1 Blue (#2196F3) - Water, Helicopter + /// - 2 Green (#4CAF50) - Found Person, Safe + /// - 3 Yellow (#FFC107) - Warning + /// - 4 Orange (#FF9800) - Staging Area + /// - 5 Purple (#9C27B0) - Object + /// - 6 Pink (#E91E63) - Reserved + /// - 7 Cyan (#00BCD4) - Reserved + static List get defaults { + return [ + SarTemplate( + id: 'default_found_person', + emoji: '🧑', + name: 'Found Person', + description: '', + colorHex: colorPalette[2], // Green + isDefault: true, + ), + SarTemplate( + id: 'default_fire', + emoji: '🔥', + name: 'Fire', + description: '', + colorHex: colorPalette[0], // Red + isDefault: true, + ), + SarTemplate( + id: 'default_staging_area', + emoji: '🏕️', + name: 'Staging Area', + description: '', + colorHex: colorPalette[4], // Orange + isDefault: true, + ), + SarTemplate( + id: 'default_object', + emoji: '📦', + name: 'Object', + description: '', + colorHex: colorPalette[5], // Purple + isDefault: true, + ), + ]; + } +} diff --git a/lib/models/sent_message_tracker.dart b/lib/models/sent_message_tracker.dart new file mode 100644 index 0000000..1d38868 --- /dev/null +++ b/lib/models/sent_message_tracker.dart @@ -0,0 +1 @@ +export 'package:meshcore_client/meshcore_client.dart' show SentMessageTracker; diff --git a/lib/models/update_info.dart b/lib/models/update_info.dart new file mode 100644 index 0000000..bc3b0de --- /dev/null +++ b/lib/models/update_info.dart @@ -0,0 +1,52 @@ +/// Information about an available app update +class UpdateInfo { + final bool isAvailable; + final String currentCommitHash; + final String? latestCommitHash; + final String? downloadUrl; + final String? buildId; + final String? timestamp; + + const UpdateInfo({ + required this.isAvailable, + required this.currentCommitHash, + this.latestCommitHash, + this.downloadUrl, + this.buildId, + this.timestamp, + }); + + /// Factory constructor for when no update is available + factory UpdateInfo.noUpdate(String currentCommitHash) { + return UpdateInfo( + isAvailable: false, + currentCommitHash: currentCommitHash, + ); + } + + /// Factory constructor for when an update is available + factory UpdateInfo.available({ + required String currentCommitHash, + required String latestCommitHash, + required String downloadUrl, + String? buildId, + String? timestamp, + }) { + return UpdateInfo( + isAvailable: true, + currentCommitHash: currentCommitHash, + latestCommitHash: latestCommitHash, + downloadUrl: downloadUrl, + buildId: buildId, + timestamp: timestamp, + ); + } + + @override + String toString() { + return 'UpdateInfo(isAvailable: $isAvailable, ' + 'current: $currentCommitHash, ' + 'latest: $latestCommitHash, ' + 'downloadUrl: $downloadUrl)'; + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart new file mode 100644 index 0000000..ade792b --- /dev/null +++ b/lib/providers/app_provider.dart @@ -0,0 +1,4474 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import '../l10n/app_localizations.dart'; +import 'package:meshcore_client/meshcore_client.dart' + show BufferReader, MeshCoreConstants; +import 'package:shared_preferences/shared_preferences.dart'; +import 'connection_provider.dart'; +import 'contacts_provider.dart'; +import 'messages_provider.dart'; +import 'drawing_provider.dart'; +import 'channels_provider.dart'; +import 'voice_provider.dart'; +import 'image_provider.dart' as ip; +import 'helpers/fragment_ack_wait_registry.dart'; +import 'helpers/session_metadata_restore.dart'; +import '../services/location_tracking_service.dart'; +import '../services/messaging_route_preferences.dart'; +import '../services/nearest_router_selector.dart'; +import '../services/packet_capture_storage_service.dart'; +import '../services/path_history_service.dart'; +import '../services/traffic_stats_reporting_service.dart'; +import '../utils/rssi_location_estimator.dart'; +import '../services/profiles_feature_service.dart'; +import '../services/route_hash_preferences.dart'; +import '../services/notification_service.dart'; +import '../models/contact.dart'; +import '../models/message.dart'; +import '../models/ble_packet_log.dart'; +import '../models/path_selection.dart'; +import '../models/message_reception_details.dart'; +import '../utils/drawing_message_parser.dart'; +import '../utils/raw_route_probe.dart'; +import '../utils/voice_message_parser.dart'; +import '../utils/image_message_parser.dart'; +import '../utils/media_swarm_protocol.dart'; +import '../utils/message_airtime_estimator.dart'; +import '../utils/fast_gps_packet.dart'; +import '../utils/log_rx_route_decoder.dart'; + +class _DirectMessageRouteSession { + final PathSelection currentSelection; + final ParsedContactRoute? originalRoute; + final bool routerFallbackAttempted; + + const _DirectMessageRouteSession({ + required this.currentSelection, + required this.originalRoute, + required this.routerFallbackAttempted, + }); + + _DirectMessageRouteSession copyWith({ + PathSelection? currentSelection, + ParsedContactRoute? originalRoute, + bool? routerFallbackAttempted, + }) { + return _DirectMessageRouteSession( + currentSelection: currentSelection ?? this.currentSelection, + originalRoute: originalRoute ?? this.originalRoute, + routerFallbackAttempted: + routerFallbackAttempted ?? this.routerFallbackAttempted, + ); + } +} + +class _ParsedRawAdvert { + final Uint8List publicKey; + final String? advName; + final int typeValue; + final int flags; + final int lastAdvert; + final int? advLat; + final int? advLon; + final int? signedEncodedPathLen; + final Uint8List? paddedPathBytes; + + const _ParsedRawAdvert({ + required this.publicKey, + required this.advName, + required this.typeValue, + required this.flags, + required this.lastAdvert, + required this.advLat, + required this.advLon, + required this.signedEncodedPathLen, + required this.paddedPathBytes, + }); +} + +class _ParsedRepeaterStatus { + final int batteryMv; + final int queueLen; + final int lastRssi; + final int lastSnrRaw; + final int uptimeSecs; + + const _ParsedRepeaterStatus({ + required this.batteryMv, + required this.queueLen, + required this.lastRssi, + required this.lastSnrRaw, + required this.uptimeSecs, + }); +} + +class _PendingRepeaterOwnerRequest { + final Uint8List publicKey; + + const _PendingRepeaterOwnerRequest({required this.publicKey}); +} + +enum ContactsTabSection { + favourites, + teamMembers, + repeaters, + sensors, + rooms, + channels, +} + +enum ChannelLocationSharingMode { hardware, appFallback } + +class ChannelLocationSharingState { + final ChannelLocationSharingMode mode; + final bool isSharing; + final bool hardwareSupported; + final bool isConnected; + + const ChannelLocationSharingState({ + required this.mode, + required this.isSharing, + required this.hardwareSupported, + required this.isConnected, + }); + + bool get usesHardware => mode == ChannelLocationSharingMode.hardware; +} + +class ChannelLocationSharingResult { + final ChannelLocationSharingState state; + final String message; + + const ChannelLocationSharingResult({ + required this.state, + required this.message, + }); +} + +/// Radio fast-GPS region scope, mirroring the firmware's `fast_gps_region` +/// custom var. Index 0 is unscoped (flood everywhere); higher indices restrict +/// beacons to a named region. `labels` comes from the firmware so the picker +/// always matches the regions that build supports. +class FastGpsRegionState { + final List labels; + final int selectedIndex; + + const FastGpsRegionState({required this.labels, required this.selectedIndex}); + + String get selectedLabel => + (selectedIndex >= 0 && selectedIndex < labels.length) + ? labels[selectedIndex] + : (labels.isNotEmpty ? labels.first : 'Unscoped'); +} + +/// Main App Provider - coordinates all other providers +class AppProvider with ChangeNotifier { + static const int _maxDirectPayloadHops = 3; + static const int _rawPayloadTypeAdvert = 0x04; + static const int _routeTransportFlood = 0x00; + static const int _routeTransportDirect = 0x03; + static const int _anonReqTypeOwner = 0x02; + static const double _lowBatteryThresholdPercent = 30.0; + static const double _lowBatteryResetThresholdPercent = 35.0; + static const Duration _lowBatteryCheckInterval = Duration(minutes: 5); + static const Duration _repeaterOwnerInfoRequestCooldown = Duration( + minutes: 10, + ); + @visibleForTesting + static bool isDeletedChannelInfo( + int channelIdx, + String channelName, + Uint8List secret, + ) { + return channelIdx != 0 && + channelName.isEmpty && + secret.every((byte) => byte == 0); + } + + @visibleForTesting + static String channelContactName(int channelIdx, String channelName) { + return channelName.isEmpty && channelIdx != 0 + ? 'Channel $channelIdx' + : channelName; + } + + final ConnectionProvider connectionProvider; + final ContactsProvider contactsProvider; + final MessagesProvider messagesProvider; + final DrawingProvider drawingProvider; + final ChannelsProvider channelsProvider; + final VoiceProvider voiceProvider; + final ip.ImageProvider imageProvider; + final LocationTrackingService locationTrackingService = + LocationTrackingService(); + final PacketCaptureStorageService packetCaptureStorageService = + PacketCaptureStorageService(); + final TrafficStatsReportingService trafficStatsReportingService = + TrafficStatsReportingService(); + final NotificationService _notificationService = NotificationService(); + + bool _isInitialized = false; + bool get isInitialized => _isInitialized; + + bool _isSimpleMode = false; + bool get isSimpleMode => _isSimpleMode; + + bool _isMapEnabled = true; + bool get isMapEnabled => _isMapEnabled; + bool _isContactsEnabled = true; + bool get isContactsEnabled => _isContactsEnabled; + bool _isSensorsEnabled = true; + bool get isSensorsEnabled => _isSensorsEnabled; + final Map _contactsSectionVisibility = { + for (final section in ContactsTabSection.values) section: true, + }; + + bool _isVoiceSilenceTrimmingEnabled = true; + bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; + bool _isVoiceBandPassFilterEnabled = true; + bool get isVoiceBandPassFilterEnabled => _isVoiceBandPassFilterEnabled; + bool _isVoiceCompressorEnabled = true; + bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled; + bool _isVoiceLimiterEnabled = true; + bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled; + bool _isVoiceAutoGainEnabled = false; + bool get isVoiceAutoGainEnabled => _isVoiceAutoGainEnabled; + bool _isVoiceEchoCancellationEnabled = false; + bool get isVoiceEchoCancellationEnabled => _isVoiceEchoCancellationEnabled; + bool _isVoiceNoiseSuppressionEnabled = false; + bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled; + double _messageFontScale = 1.0; + double get messageFontScale => _messageFontScale; + bool _clearPathOnMaxRetry = + MessagingRoutePreferences.defaultClearPathOnMaxRetry; + bool get clearPathOnMaxRetry => _clearPathOnMaxRetry; + bool _nearestRelayFallbackEnabled = + MessagingRoutePreferences.defaultNearestRelayFallbackEnabled; + bool get nearestRelayFallbackEnabled => _nearestRelayFallbackEnabled; + final PathHistoryService _pathHistoryService = PathHistoryService(); + final NearestRouterSelector _nearestRouterSelector = + const NearestRouterSelector(); + final Map _directMessageRouteSessions = + {}; + final Set _pendingDeliveredRouteRefreshContacts = {}; + + static const Duration _packetRetryDelay = Duration(milliseconds: 1200); + static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10); + static const int _maxPacketRetryAttempts = 4; + final Map _voiceSessionSenderKey6 = {}; + final Map _imageSessionSenderKey6 = {}; + final Map> _pendingChannelVoicePackets = {}; + final Map> _pendingChannelImageFragments = {}; + final Map _voiceMissingRetryTimers = {}; + final Map _voiceMissingRetryAttempts = {}; + final Map _imageMissingRetryTimers = {}; + final Map _imageMissingRetryAttempts = {}; + bool _hardwareChannelLocationSharingSupported = false; + int? _hardwareChannelLocationSharingChannelIdx; + final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry(); + final Map> _pendingRawRouteProbes = {}; + final Map> _pendingMediaSwarmFetches = {}; + final Map> + _pendingMediaSwarmResponses = {}; + final Map _recentRepeaterStatusRequests = {}; + final Map _recentRepeaterOwnerInfoRequests = {}; + final Map _pendingRepeaterOwnerRequests = + {}; + bool _fastLocationScreenActive = false; + Timer? _packetCaptureFlushTimer; + Timer? _lowBatteryCheckTimer; + String? _lastPersistedPacketSignature; + bool _isPersistingPacketCapture = false; + bool _wasDeviceConnected = false; + bool _hasCompletedConnectionBootstrap = false; + bool _isReconnectSyncInProgress = false; + final Set _lowBatteryNotifiedNodeIds = {}; + + AppProvider({ + required this.connectionProvider, + required this.contactsProvider, + required this.messagesProvider, + required this.drawingProvider, + required this.channelsProvider, + required this.voiceProvider, + required this.imageProvider, + }) { + _setupCallbacks(); + connectionProvider.canStartAutomaticMessageSyncCallback = + _canStartAutomaticMessageSync; + _wasDeviceConnected = connectionProvider.deviceInfo.isConnected; + _initializeLocationTracking(); + _loadSimpleMode(); + _loadMapEnabled(); + _loadContactsEnabled(); + _loadContactsSectionVisibility(); + _loadSensorsEnabled(); + _loadVoiceSilenceTrimmingEnabled(); + _loadVoiceBandPassFilterEnabled(); + _loadVoiceCompressorEnabled(); + _loadVoiceLimiterEnabled(); + _loadVoiceAutoGainEnabled(); + _loadVoiceEchoCancellationEnabled(); + _loadVoiceNoiseSuppressionEnabled(); + _loadMessageFontScale(); + _loadMessagingRouteSettings(); + unawaited( + trafficStatsReportingService.initialize( + deviceKey6Provider: _deviceKey6Hex, + ), + ); + unawaited(_pathHistoryService.initialize()); + _startPacketCapturePersistence(); + _startLowBatteryWatcher(); + _syncDrawingsOnStartup(); // Sync drawings immediately after providers load + _isInitialized = true; + } + + String _scopedKey(String baseKey) { + return ProfileStorageScope.scopedKey(baseKey); + } + + bool isContactsSectionEnabled(ContactsTabSection section) { + return _contactsSectionVisibility[section] ?? true; + } + + String _contactsSectionVisibilityKey(ContactsTabSection section) { + switch (section) { + case ContactsTabSection.favourites: + return 'contacts_section_favourites_enabled'; + case ContactsTabSection.teamMembers: + return 'contacts_section_team_members_enabled'; + case ContactsTabSection.repeaters: + return 'contacts_section_repeaters_enabled'; + case ContactsTabSection.sensors: + return 'contacts_section_sensors_enabled'; + case ContactsTabSection.rooms: + return 'contacts_section_rooms_enabled'; + case ContactsTabSection.channels: + return 'contacts_section_channels_enabled'; + } + } + + void _startPacketCapturePersistence() { + _packetCaptureFlushTimer?.cancel(); + _packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) { + unawaited(_flushPacketCaptureLogs()); + }); + unawaited(_flushPacketCaptureLogs()); + } + + void _startLowBatteryWatcher() { + _lowBatteryCheckTimer?.cancel(); + _lowBatteryCheckTimer = Timer.periodic(_lowBatteryCheckInterval, (_) { + unawaited(_checkLowBatteryAlerts()); + }); + unawaited(_checkLowBatteryAlerts()); + } + + Future _checkLowBatteryAlerts() async { + final deviceBattery = connectionProvider.deviceInfo.batteryPercent; + if (deviceBattery != null && + deviceBattery > _lowBatteryResetThresholdPercent) { + _lowBatteryNotifiedNodeIds.remove('device'); + } + + if (connectionProvider.deviceInfo.isConnected && deviceBattery != null) { + await _notifyLowBatteryIfNeeded( + nodeId: 'device', + nodeName: + connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true + ? connectionProvider.deviceInfo.selfName!.trim() + : (connectionProvider.deviceInfo.displayName ?? 'Connected device'), + batteryPercent: deviceBattery, + isCurrentDevice: true, + ); + } + } + + Future _notifyLowBatteryIfNeeded({ + required String nodeId, + required String nodeName, + required double batteryPercent, + required bool isCurrentDevice, + }) async { + if (batteryPercent >= _lowBatteryThresholdPercent) { + return; + } + if (_lowBatteryNotifiedNodeIds.contains(nodeId)) { + return; + } + + final shown = await _notificationService.showLowBatteryNotification( + nodeId: nodeId, + nodeName: nodeName, + batteryPercent: batteryPercent, + isCurrentDevice: isCurrentDevice, + ); + if (shown) { + _lowBatteryNotifiedNodeIds.add(nodeId); + } + } + + String _packetLogSignature(BlePacketLog log) { + final prefix = log.rawData.length <= 12 + ? log.rawData + : log.rawData.sublist(0, 12); + final prefixHex = prefix + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + return '${log.timestamp.microsecondsSinceEpoch}|' + '${log.direction.name}|${log.responseCode ?? -1}|' + '${log.rawData.length}|$prefixHex'; + } + + Future _flushPacketCaptureLogs() async { + if (_isPersistingPacketCapture) return; + _isPersistingPacketCapture = true; + try { + final logs = connectionProvider.bleService.packetLogs; + if (logs.isEmpty) return; + + List toPersist = const []; + if (_lastPersistedPacketSignature == null) { + toPersist = logs; + } else { + final lastSig = _lastPersistedPacketSignature!; + var lastIndex = -1; + for (var i = logs.length - 1; i >= 0; i--) { + if (_packetLogSignature(logs[i]) == lastSig) { + lastIndex = i; + break; + } + } + if (lastIndex == -1) { + // In-memory log rotated or cleared; persist current window to avoid gaps. + toPersist = logs; + } else if (lastIndex < logs.length - 1) { + toPersist = logs.sublist(lastIndex + 1); + } + } + + if (toPersist.isNotEmpty) { + await packetCaptureStorageService.appendLogs(toPersist); + await trafficStatsReportingService.processLogs(toPersist); + } + _lastPersistedPacketSignature = _packetLogSignature(logs.last); + } catch (e) { + debugPrint('❌ [AppProvider] Packet capture flush failed: $e'); + } finally { + _isPersistingPacketCapture = false; + } + } + + /// Sync drawings from messages on app startup (before BLE connection) + Future _syncDrawingsOnStartup() async { + // Wait for both MessagesProvider and DrawingProvider to finish initializing + int attempts = 0; + while ((!messagesProvider.isInitialized || + !drawingProvider.isInitialized) && + attempts < 40) { + await Future.delayed(const Duration(milliseconds: 50)); + attempts++; + } + + _restoreSessionMetadataFromMessages(); + + debugPrint( + '🔄 [AppProvider] Early sync: syncing drawings from messages...', + ); + messagesProvider.syncDrawingsWithProvider(drawingProvider); + } + + void _restoreSessionMetadataFromMessages() { + final restored = restoreSessionMetadataFromMessages( + messagesProvider.messages, + ); + + _voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession); + _imageSessionSenderKey6.addAll(restored.imageSenderKeyBySession); + for (final entry in restored.imageEnvelopeBySession.entries) { + imageProvider.registerEnvelope(entry.value); + } + + final restoredVoice = restored.voiceSenderKeyBySession.length; + final restoredImage = restored.imageEnvelopeBySession.length; + if (restoredVoice > 0 || restoredImage > 0) { + debugPrint( + '🔄 [AppProvider] Restored session metadata from messages: ' + '$restoredVoice voice, $restoredImage image', + ); + } + } + + String? _resolveContactNameForNotification(Uint8List? publicKey) { + if (publicKey == null || publicKey.isEmpty) return null; + + final ownPublicKey = connectionProvider.deviceInfo.publicKey; + final ownName = _preferredSelfDisplayName( + deviceName: connectionProvider.deviceInfo.deviceName, + selfName: connectionProvider.deviceInfo.selfName, + ); + if (_matchesPublicKeyPrefix(publicKey, ownPublicKey) && + ownName != null && + ownName.trim().isNotEmpty) { + return ownName; + } + + Contact? contact; + if (publicKey.length >= 32) { + contact = contactsProvider.findContactByKey(publicKey); + } + contact ??= publicKey.length >= 6 + ? contactsProvider.findContactByPrefix( + Uint8List.fromList(publicKey.sublist(0, 6)), + ) + : null; + return contact?.advName; + } + + static bool shouldIgnoreSelfReplay({ + required Message message, + required Uint8List? ownPublicKey, + required String? ownName, + }) { + if ((!message.isContactMessage && !message.isChannelMessage) || + message.pathLen > 0) { + return false; + } + + if (_matchesPublicKeyPrefix(message.senderPublicKeyPrefix, ownPublicKey)) { + return true; + } + + final trimmedSenderName = message.senderName?.trim(); + final trimmedOwnName = ownName?.trim(); + if (trimmedSenderName == null || + trimmedSenderName.isEmpty || + trimmedOwnName == null || + trimmedOwnName.isEmpty) { + return false; + } + + return trimmedSenderName == trimmedOwnName; + } + + static String? preferredSelfDisplayName({ + required String? deviceName, + required String? selfName, + }) => _preferredSelfDisplayName(deviceName: deviceName, selfName: selfName); + + static String? _preferredSelfDisplayName({ + required String? deviceName, + required String? selfName, + }) { + final trimmedSelfName = selfName?.trim(); + if (trimmedSelfName != null && trimmedSelfName.isNotEmpty) { + return trimmedSelfName; + } + + final trimmedDeviceName = deviceName?.trim(); + if (trimmedDeviceName == null || trimmedDeviceName.isEmpty) { + return null; + } + + if (trimmedDeviceName.toLowerCase().startsWith('meshcore-')) { + final stripped = trimmedDeviceName.substring('meshcore-'.length).trim(); + if (stripped.isNotEmpty) { + return stripped; + } + } + + return trimmedDeviceName; + } + + static bool _matchesPublicKeyPrefix( + Uint8List? candidateKey, + Uint8List? ownPublicKey, + ) { + if (candidateKey == null || + candidateKey.isEmpty || + ownPublicKey == null || + ownPublicKey.isEmpty) { + return false; + } + + final compareLength = candidateKey.length >= 6 && ownPublicKey.length >= 6 + ? 6 + : (candidateKey.length < ownPublicKey.length + ? candidateKey.length + : ownPublicKey.length); + if (compareLength <= 0) { + return false; + } + + for (var index = 0; index < compareLength; index++) { + if (candidateKey[index] != ownPublicKey[index]) { + return false; + } + } + return true; + } + + /// Load simple mode setting from shared preferences + Future _loadSimpleMode() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isSimpleMode = prefs.getBool(_scopedKey('simple_mode_enabled')) ?? false; + notifyListeners(); + } catch (e) { + debugPrint('Error loading simple mode setting: $e'); + } + } + + /// Toggle simple mode on/off + Future toggleSimpleMode(bool enabled) async { + try { + _isSimpleMode = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_scopedKey('simple_mode_enabled'), enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving simple mode setting: $e'); + } + } + + /// Load map enabled setting from shared preferences + Future _loadMapEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isMapEnabled = prefs.getBool(_scopedKey('map_enabled')) ?? true; + notifyListeners(); + } catch (e) { + debugPrint('Error loading map enabled setting: $e'); + } + } + + /// Toggle map on/off + Future toggleMapEnabled(bool enabled) async { + try { + _isMapEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_scopedKey('map_enabled'), enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving map enabled setting: $e'); + } + } + + /// Load contacts enabled setting from shared preferences + Future _loadContactsEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isContactsEnabled = + prefs.getBool(_scopedKey('contacts_enabled')) ?? true; + notifyListeners(); + } catch (e) { + debugPrint('Error loading contacts enabled setting: $e'); + } + } + + /// Toggle contacts tab on/off + Future toggleContactsEnabled(bool enabled) async { + try { + _isContactsEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_scopedKey('contacts_enabled'), enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving contacts enabled setting: $e'); + } + } + + Future _loadContactsSectionVisibility() async { + try { + final prefs = await SharedPreferences.getInstance(); + for (final section in ContactsTabSection.values) { + _contactsSectionVisibility[section] = + prefs.getBool(_scopedKey(_contactsSectionVisibilityKey(section))) ?? + true; + } + notifyListeners(); + } catch (e) { + debugPrint('Error loading contacts section visibility settings: $e'); + } + } + + Future setContactsSectionEnabled( + ContactsTabSection section, + bool enabled, + ) async { + try { + _contactsSectionVisibility[section] = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + _scopedKey(_contactsSectionVisibilityKey(section)), + enabled, + ); + notifyListeners(); + } catch (e) { + debugPrint('Error saving contacts section visibility setting: $e'); + } + } + + /// Load sensors enabled setting from shared preferences + Future _loadSensorsEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isSensorsEnabled = prefs.getBool(_scopedKey('sensors_enabled')) ?? true; + notifyListeners(); + } catch (e) { + debugPrint('Error loading sensors enabled setting: $e'); + } + } + + /// Toggle sensors tab on/off + Future toggleSensorsEnabled(bool enabled) async { + try { + _isSensorsEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_scopedKey('sensors_enabled'), enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving sensors enabled setting: $e'); + } + } + + /// Load voice silence trimming setting from shared preferences. + Future _loadVoiceSilenceTrimmingEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isVoiceSilenceTrimmingEnabled = + prefs.getBool(_scopedKey('voice_silence_trimming_enabled')) ?? true; + notifyListeners(); + } catch (e) { + debugPrint('Error loading voice silence trimming setting: $e'); + } + } + + /// Toggle voice silence trimming on/off. + Future toggleVoiceSilenceTrimmingEnabled(bool enabled) async { + try { + _isVoiceSilenceTrimmingEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + _scopedKey('voice_silence_trimming_enabled'), + enabled, + ); + notifyListeners(); + } catch (e) { + debugPrint('Error saving voice silence trimming setting: $e'); + } + } + + /// Load voice band-pass filter setting from shared preferences. + Future _loadVoiceBandPassFilterEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isVoiceBandPassFilterEnabled = + prefs.getBool(_scopedKey('voice_band_pass_filter_enabled')) ?? true; + notifyListeners(); + } catch (e) { + debugPrint('Error loading voice band-pass filter setting: $e'); + } + } + + /// Toggle voice band-pass filter on/off. + Future toggleVoiceBandPassFilterEnabled(bool enabled) async { + try { + _isVoiceBandPassFilterEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + _scopedKey('voice_band_pass_filter_enabled'), + enabled, + ); + notifyListeners(); + } catch (e) { + debugPrint('Error saving voice band-pass filter setting: $e'); + } + } + + /// Load voice compressor setting from shared preferences. + Future _loadVoiceCompressorEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isVoiceCompressorEnabled = + prefs.getBool(_scopedKey('voice_compressor_enabled')) ?? true; + notifyListeners(); + } catch (e) { + debugPrint('Error loading voice compressor setting: $e'); + } + } + + /// Toggle voice compressor on/off. + Future toggleVoiceCompressorEnabled(bool enabled) async { + try { + _isVoiceCompressorEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_scopedKey('voice_compressor_enabled'), enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving voice compressor setting: $e'); + } + } + + /// Load voice limiter setting from shared preferences. + Future _loadVoiceLimiterEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isVoiceLimiterEnabled = + prefs.getBool(_scopedKey('voice_limiter_enabled')) ?? true; + notifyListeners(); + } catch (e) { + debugPrint('Error loading voice limiter setting: $e'); + } + } + + /// Toggle voice limiter on/off. + Future toggleVoiceLimiterEnabled(bool enabled) async { + try { + _isVoiceLimiterEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_scopedKey('voice_limiter_enabled'), enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving voice limiter setting: $e'); + } + } + + Future _loadVoiceAutoGainEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isVoiceAutoGainEnabled = + prefs.getBool(_scopedKey('voice_auto_gain_enabled')) ?? false; + notifyListeners(); + } catch (e) { + debugPrint('Error loading voice auto gain setting: $e'); + } + } + + Future toggleVoiceAutoGainEnabled(bool enabled) async { + try { + _isVoiceAutoGainEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_scopedKey('voice_auto_gain_enabled'), enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving voice auto gain setting: $e'); + } + } + + Future _loadVoiceEchoCancellationEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isVoiceEchoCancellationEnabled = + prefs.getBool(_scopedKey('voice_echo_cancellation_enabled')) ?? false; + notifyListeners(); + } catch (e) { + debugPrint('Error loading voice echo cancellation setting: $e'); + } + } + + Future toggleVoiceEchoCancellationEnabled(bool enabled) async { + try { + _isVoiceEchoCancellationEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + _scopedKey('voice_echo_cancellation_enabled'), + enabled, + ); + notifyListeners(); + } catch (e) { + debugPrint('Error saving voice echo cancellation setting: $e'); + } + } + + Future _loadVoiceNoiseSuppressionEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isVoiceNoiseSuppressionEnabled = + prefs.getBool(_scopedKey('voice_noise_suppression_enabled')) ?? false; + notifyListeners(); + } catch (e) { + debugPrint('Error loading voice noise suppression setting: $e'); + } + } + + Future toggleVoiceNoiseSuppressionEnabled(bool enabled) async { + try { + _isVoiceNoiseSuppressionEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + _scopedKey('voice_noise_suppression_enabled'), + enabled, + ); + notifyListeners(); + } catch (e) { + debugPrint('Error saving voice noise suppression setting: $e'); + } + } + + Future _loadMessageFontScale() async { + try { + final prefs = await SharedPreferences.getInstance(); + _messageFontScale = + (prefs.getDouble(_scopedKey('message_font_scale')) ?? 1.0).clamp( + 0.85, + 1.4, + ); + notifyListeners(); + } catch (e) { + debugPrint('Error loading message font scale setting: $e'); + } + } + + Future setMessageFontScale(double scale) async { + try { + _messageFontScale = scale.clamp(0.85, 1.4); + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble( + _scopedKey('message_font_scale'), + _messageFontScale, + ); + notifyListeners(); + } catch (e) { + debugPrint('Error saving message font scale setting: $e'); + } + } + + Future _loadMessagingRouteSettings() async { + try { + await MessagingRoutePreferences.cleanupLegacySettings(); + _clearPathOnMaxRetry = + await MessagingRoutePreferences.getClearPathOnMaxRetry(); + _nearestRelayFallbackEnabled = + await MessagingRoutePreferences.getNearestRelayFallbackEnabled(); + notifyListeners(); + } catch (e) { + debugPrint('Error loading messaging route settings: $e'); + } + } + + Future toggleClearPathOnMaxRetry(bool enabled) async { + try { + _clearPathOnMaxRetry = enabled; + await MessagingRoutePreferences.setClearPathOnMaxRetry(enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving clear path on max retry setting: $e'); + } + } + + Future toggleNearestRelayFallbackEnabled(bool enabled) async { + try { + _nearestRelayFallbackEnabled = enabled; + await MessagingRoutePreferences.setNearestRelayFallbackEnabled(enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving nearest relay fallback setting: $e'); + } + } + + /// Initialize location tracking service + Future _initializeLocationTracking() async { + try { + // Initialize location tracking with BLE service + await locationTrackingService.initialize(connectionProvider.bleService); + + // Setup callbacks + locationTrackingService.onPositionUpdate = (position) { + debugPrint( + '📍 [AppProvider] Position updated: ${position.latitude}, ${position.longitude}', + ); + }; + + locationTrackingService.onBroadcastSent = (position) { + debugPrint('📡 [AppProvider] Position broadcast to mesh network'); + }; + + locationTrackingService.onError = (error) { + debugPrint('❌ [AppProvider] Location tracking error: $error'); + }; + + locationTrackingService.onTrackingStateChanged = (isTracking) { + debugPrint( + '🔄 [AppProvider] Location tracking state: ${isTracking ? "started" : "stopped"}', + ); + }; + + locationTrackingService.onFastLocationUpdate = + (latitude, longitude, speedKmh, reason) { + unawaited( + _sendFastLocationUpdate( + latitude: latitude, + longitude: longitude, + speedKmh: speedKmh, + reason: reason, + ), + ); + }; + + debugPrint('✅ [AppProvider] Location tracking service initialized'); + } catch (e) { + debugPrint('❌ [AppProvider] Error initializing location tracking: $e'); + } + } + + /// Setup callbacks between providers + void _setupCallbacks() { + // Monitor connection state changes to start/stop location tracking + connectionProvider.addListener(_handleConnectionStateChange); + messagesProvider.resolveContactNameCallback = + _resolveContactNameForNotification; + messagesProvider.resolveChannelNameCallback = (channelIdx) { + if (channelIdx == 0) { + return 'Public'; + } + + for (final channel in contactsProvider.channels) { + if (channel.publicKey.length > 1 && + channel.publicKey[1] == channelIdx) { + return channel.displayName; + } + } + + return channelsProvider.getChannelDisplayName(channelIdx); + }; + + voiceProvider.sendRawPacketCallback = + ({ + required Uint8List contactPath, + required int contactPathLen, + required Uint8List payload, + }) async { + await connectionProvider.sendRawVoicePacket( + contactPath: contactPath, + contactPathLen: contactPathLen, + payload: payload, + ); + }; + + // Image raw-packet serving reuses the same BLE raw-data path as voice. + imageProvider.sendRawPacketCallback = + ({ + required Uint8List contactPath, + required int contactPathLen, + required Uint8List payload, + }) async { + await connectionProvider.sendRawVoicePacket( + contactPath: contactPath, + contactPathLen: contactPathLen, + payload: payload, + ); + }; + // When a contact is received from BLE + connectionProvider.onContactReceivedDetailed = (contact, source) { + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final existingContact = contactsProvider.findContactByKey( + contact.publicKey, + ); + final isAdvertSource = + source == ContactReceiveSource.advert || + source == ContactReceiveSource.preview; + + if (isAdvertSource) { + // For pushNewAdvert (0x8A): contact data is already in the push, but + // we also import into firmware so subsequent getContact calls work. + // Matches the official app which calls cmdGetAdvertPath for all adverts. + if (source == ContactReceiveSource.advert) { + unawaited(connectionProvider.importReceivedAdvert(contact.publicKey)); + } + final isNewPendingAdvert = contactsProvider + .addOrUpdatePendingAdvertContact( + contact, + devicePublicKey: devicePublicKey, + ); + if (isNewPendingAdvert) { + unawaited( + _notificationService.showContactDiscoveredNotification( + contactKey: contact.publicKey + .take(6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(), + contactName: contact.advName.trim().isEmpty + ? null + : contact.advName, + ), + ); + } + if (contact.type == ContactType.repeater && + contact.advName.trim().isEmpty) { + unawaited(_maybeRequestRepeaterOwnerInfo(contact.publicKey)); + } + if (existingContact == null) { + return; + } + } + + // Pass device public key to filter out our own contact + contactsProvider.addOrUpdateContact( + contact, + devicePublicKey: devicePublicKey, + ); + + final updatedContact = + contactsProvider.findContactByKey(contact.publicKey) ?? contact; + if (_pendingDeliveredRouteRefreshContacts.remove( + updatedContact.publicKeyHex, + )) { + messagesProvider.applyDeliveredMessageRouteFromContact(updatedContact); + } + }; + + // When all contacts are received + connectionProvider.onContactsComplete = (contacts) { + // Pass device public key to filter out our own contact + contactsProvider.addContacts( + contacts, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + debugPrint('Received ${contacts.length} contacts'); + }; + + // Setup callback for ConnectionProvider to query channel info + connectionProvider.getChannelInfo = (int channelIdx) { + return channelsProvider.getChannel(channelIdx); + }; + + // When channel info is received + connectionProvider.onChannelInfoReceived = + (int channelIdx, String channelName, Uint8List secret, int? flags) { + try { + debugPrint( + '🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"', + ); + + final isDeletedChannel = connectionProvider + .shouldTreatChannelInfoAsDeleted( + channelIdx, + channelName, + secret, + ); + final isEmptyChannelInfo = AppProvider.isDeletedChannelInfo( + channelIdx, + channelName, + secret, + ); + final contactChannelName = AppProvider.channelContactName( + channelIdx, + channelName, + ); + + // Only treat the slot as deleted when firmware returns an empty + // name and a zeroed secret. Protected channels may still have an + // empty display name but remain fully configured. + if (isDeletedChannel) { + debugPrint( + ' 🗑️ Channel $channelIdx deleted - removing from providers', + ); + + messagesProvider.clearChannelMessages(channelIdx); + debugPrint(' ✅ Cleared channel messages'); + + // Remove from ChannelsProvider + channelsProvider.removeChannel(channelIdx); + debugPrint(' ✅ Removed from ChannelsProvider'); + + // Remove from ContactsProvider using pseudo public key + final publicKeyBytes = Uint8List(32); + publicKeyBytes[0] = 0xFF; // Special marker for channels + publicKeyBytes[1] = channelIdx; // Channel index + final publicKeyHex = publicKeyBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + + contactsProvider.removeContact(publicKeyHex); + debugPrint(' ✅ Removed from ContactsProvider'); + + return; + } + + if (isEmptyChannelInfo) { + // The device channel list is authoritative: an empty slot means + // any locally retained channel in that slot was deleted on the + // device, so drop it from the channel list and the contacts UI. + // Message history is kept (only app-initiated deletes clear it). + debugPrint( + ' 🧹 Empty channel slot $channelIdx - dropping stale local channel if present', + ); + channelsProvider.removeChannel(channelIdx); + + final publicKeyBytes = Uint8List(32); + publicKeyBytes[0] = 0xFF; // Special marker for channels + publicKeyBytes[1] = channelIdx; // Channel index + final publicKeyHex = publicKeyBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + contactsProvider.removeContact(publicKeyHex); + return; + } + + // Add/update in ChannelsProvider + channelsProvider.addOrUpdateChannel( + index: channelIdx, + name: channelName, + secret: secret, + flags: flags, + ); + debugPrint(' ✅ Added to ChannelsProvider'); + + // Also add as Contact to ContactsProvider (for UI display) + // Skip if it's public channel (already exists) + debugPrint( + '📻 [AppProvider] Channel $channelIdx: "$contactChannelName" (rawNameEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})', + ); + + if (channelIdx != 0) { + debugPrint( + ' ✅ Adding channel $channelIdx to ContactsProvider as Contact', + ); + + // Create a pseudo public key for the channel based on its index + // Use channel index as a unique identifier (pad to 32 bytes) + final publicKeyBytes = Uint8List(32); + publicKeyBytes[0] = 0xFF; // Special marker for channels + publicKeyBytes[1] = channelIdx; // Channel index + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + contactsProvider.addOrUpdateContact( + Contact( + publicKey: publicKeyBytes, + type: ContactType.channel, + flags: flags ?? 0, + outPathLen: -1, // Flood mode for channels + outPath: Uint8List(0), // Empty path for channels + advName: contactChannelName, + lastAdvert: now, + advLat: 0, // Channels don't have location + advLon: 0, + lastMod: now, + isNew: false, // Don't mark channels as new + ), + ); + + debugPrint( + ' ✅ Channel contact added. Total channels in ContactsProvider: ${contactsProvider.channels.length}', + ); + } else { + debugPrint( + ' ⏭️ Skipping channel $channelIdx (isPublic: ${channelIdx == 0})', + ); + } + } catch (e, stackTrace) { + debugPrint('❌ [AppProvider] Error in onChannelInfoReceived: $e'); + debugPrint(' Stack trace: $stackTrace'); + } + }; + + // When a message is received + connectionProvider.onMessageReceived = (message) { + if (message.isChannelMessage) { + debugPrint( + '📥 [AppProvider] Channel message received: ' + 'id=${message.id}, ' + 'senderName=${message.senderName ?? "-"}, ' + 'senderKey=${message.senderKeyShort ?? "-"}, ' + 'pathLen=${message.pathLen}, ' + 'ts=${message.senderTimestamp}, ' + 'text=${message.text}', + ); + } + + if (AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: connectionProvider.deviceInfo.publicKey, + ownName: _preferredSelfDisplayName( + deviceName: connectionProvider.deviceInfo.deviceName, + selfName: connectionProvider.deviceInfo.selfName, + ), + )) { + debugPrint('⏭️ [AppProvider] Ignoring self replay: ${message.id}'); + return; + } + + // Enrich message with sender name from contacts. + // For room-forwarded messages, resolve the original poster's name + // from roomPostAuthorPrefix instead of the room server's key. + Message enrichedMessage = message; + Contact? senderContact; + if (message.senderName == null) { + // Try room post author first (the actual person who posted) + if (message.roomPostAuthorPrefix != null) { + final author = contactsProvider.findContactByPrefix( + message.roomPostAuthorPrefix!, + ); + if (author != null) { + senderContact = author; + enrichedMessage = message.copyWith(senderName: author.advName); + } + } + // Fall back to sender key (direct messages, or room with unknown author) + if (senderContact == null && message.senderPublicKeyPrefix != null) { + final contact = contactsProvider.findContactByKey( + message.senderPublicKeyPrefix!, + ); + if (contact != null) { + senderContact = contact; + enrichedMessage = message.copyWith(senderName: contact.advName); + } + } + } + senderContact ??= message.senderPublicKeyPrefix != null + ? contactsProvider.findContactByKey(message.senderPublicKeyPrefix!) + : null; + senderContact ??= enrichedMessage.senderName != null + ? contactsProvider.contacts + .where((c) => c.advName == enrichedMessage.senderName) + .firstOrNull + : null; + final contactLocationSnapshot = senderContact != null + ? contactsProvider.buildMessageContactLocationSnapshot( + senderContact, + capturedAt: enrichedMessage.receivedAt, + ) + : null; + final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot( + enrichedMessage, + ); + final receivedPathBytes = receptionDetailsSnapshot?.pathBytes; + // Estimate location for contacts without GPS using received path + if (senderContact != null && + senderContact.displayLocation == null && + receivedPathBytes != null && + receivedPathBytes.isNotEmpty) { + _estimateContactLocationFromReceivedPath( + contact: senderContact, + receivedPathBytes: receivedPathBytes, + ); + } + + // Check if message is a drawing broadcast + if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) { + debugPrint('🎨 [AppProvider] Drawing message received, parsing...'); + // Extract sender name from message packet metadata + final senderName = enrichedMessage.senderName ?? 'unknown'; + final drawing = DrawingMessageParser.parseDrawingMessage( + enrichedMessage.text, + senderName: senderName, + messageId: + enrichedMessage.id, // Pass message ID for navigation linking + ); + if (drawing != null) { + debugPrint( + '🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}', + ); + debugPrint(' Drawing linked to message ID: ${enrichedMessage.id}'); + drawingProvider.addReceivedDrawing(drawing); + + // Update message to mark as drawing and link to drawing ID + final updatedMessage = enrichedMessage.copyWith( + isDrawing: true, + drawingId: drawing.id, + ); + + // Add the drawing message to chat with drawing metadata + // This allows users to click on the drawing message to navigate to it + messagesProvider.addMessage( + updatedMessage, + contactLookup: (name) => '', + contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, + ); + } else { + debugPrint('⚠️ [AppProvider] Failed to parse drawing message'); + } + return; + } + + // Voice envelope message (new public/direct on-demand format). + final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text); + if (voiceEnvelope != null) { + final senderPrefix = enrichedMessage.senderPublicKeyPrefix; + if (senderPrefix != null && senderPrefix.length >= 6) { + _voiceSessionSenderKey6[voiceEnvelope.sessionId] = senderPrefix + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + } + voiceProvider.registerEnvelope(voiceEnvelope); + _replayPendingChannelVoicePackets(voiceEnvelope.sessionId); + enrichedMessage = enrichedMessage.copyWith( + isVoice: true, + voiceId: voiceEnvelope.sessionId, + ); + messagesProvider.addMessage( + enrichedMessage, + contactLookup: (name) { + try { + final contact = contactsProvider.contacts.firstWhere( + (c) => c.advName == name, + ); + return contact.publicKeyHex.isNotEmpty && + contact.publicKeyHex.length >= 12 + ? contact.publicKeyHex.substring(0, 12) + : ''; + } catch (_) { + return ''; + } + }, + contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, + ); + return; + } + + // Image envelope (IE1): announce image availability. + final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text); + if (imageEnvelope != null) { + final senderPrefix = enrichedMessage.senderPublicKeyPrefix; + if (senderPrefix != null && senderPrefix.length >= 6) { + _imageSessionSenderKey6[imageEnvelope.sessionId] = senderPrefix + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + } + imageProvider.registerEnvelope(imageEnvelope); + _replayPendingChannelImageFragments(imageEnvelope.sessionId); + messagesProvider.addMessage( + enrichedMessage, + contactLookup: (name) { + try { + final contact = contactsProvider.contacts.firstWhere( + (c) => c.advName == name, + ); + return contact.publicKeyHex.isNotEmpty && + contact.publicKeyHex.length >= 12 + ? contact.publicKeyHex.substring(0, 12) + : ''; + } catch (_) { + return ''; + } + }, + contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, + ); + return; + } + + // Pass contact lookup function to link channel messages with contacts + messagesProvider.addMessage( + enrichedMessage, + contactLookup: (name) { + // Find contact by name and return their public key hex (first 12 chars for 6 bytes) + try { + final contact = contactsProvider.contacts.firstWhere( + (c) => c.advName == name, + ); + return contact.publicKeyHex.isNotEmpty && + contact.publicKeyHex.length >= 12 + ? contact.publicKeyHex.substring(0, 12) + : ''; + } catch (e) { + // No matching contact found + return ''; + } + }, + contactLocationSnapshot: contactLocationSnapshot, + receptionDetailsSnapshot: receptionDetailsSnapshot, + ); + }; + + // Keep a compact receive-time snapshot because packet logs roll over. + // This lets the UI still show timing/link details after app restarts. + + // When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B) + // Used by older firmware versions for telemetry responses + connectionProvider.onTelemetryReceived = (publicKey, lppData) { + debugPrint( + '📊 [AppProvider] Telemetry response (0x8B) received - updating contact', + ); + contactsProvider.updateTelemetry(publicKey, lppData); + }; + + // When binary response is received via PUSH_CODE_BINARY_RESPONSE (0x8C) + // Used by newer firmware versions for telemetry and other binary data + // BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility + connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) { + if (_handlePendingRepeaterOwnerResponse(tag, responseData)) { + return; + } + debugPrint('📊 [AppProvider] Binary response (0x8C tag=$tag) received'); + // Binary responses carry Cayenne LPP telemetry data. + // The data starts with a channel byte — valid LPP always has at least + // 3 bytes (channel + type + value). Skip clearly non-telemetry payloads. + if (responseData.length >= 3) { + contactsProvider.updateTelemetry(publicKeyPrefix, responseData); + } + }; + + // When raw binary data is received (PUSH_CODE_RAW_DATA 0x84) + // Magic 0x6d 'm' = swarm control; 0x72 'r' = voice fetch request. + // Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet. + // Magic 0x49 'I' = image packet. + connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { + final parsedAdvert = _tryParseRawAdvert(payload); + if (parsedAdvert != null) { + final isNewPendingAdvert = contactsProvider + .addOrUpdatePendingAdvertMetadata( + publicKey: parsedAdvert.publicKey, + typeValue: parsedAdvert.typeValue, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + flags: parsedAdvert.flags, + advName: parsedAdvert.advName, + lastAdvert: parsedAdvert.lastAdvert, + advLat: parsedAdvert.advLat, + advLon: parsedAdvert.advLon, + signedEncodedPathLen: parsedAdvert.signedEncodedPathLen, + paddedPathBytes: parsedAdvert.paddedPathBytes, + rxRssiDbm: rssiDbm, + rxSnrRaw: snrRaw, + ); + if (isNewPendingAdvert) { + final contactKey = parsedAdvert.publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + unawaited( + _notificationService.showContactDiscoveredNotification( + contactKey: contactKey, + contactName: parsedAdvert.advName, + ), + ); + } + if (parsedAdvert.typeValue == ContactType.repeater.value) { + unawaited(_requestRepeaterStatus(parsedAdvert.publicKey)); + unawaited(_maybeRequestRepeaterOwnerInfo(parsedAdvert.publicKey)); + } + if (contactsProvider.shouldEnrichPendingAdvert( + parsedAdvert.publicKey, + )) { + unawaited(connectionProvider.previewContact(parsedAdvert.publicKey)); + } + return; + } + + if (_handleFastGpsPayload(payload)) { + return; + } + + final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload); + if (rawProbeRequest != null) { + debugPrint( + '📡 [AppProvider] Incoming raw route probe: nonce=${rawProbeRequest.nonce.toRadixString(16)} requester=${rawProbeRequest.requesterKey6}', + ); + _handleRawRouteProbeRequest(rawProbeRequest); + return; + } + + final rawProbeAck = RawRouteProbeAck.tryParseBinary(payload); + if (rawProbeAck != null) { + debugPrint( + '📡 [AppProvider] Incoming raw route probe ACK: nonce=${rawProbeAck.nonce.toRadixString(16)}', + ); + _completeRawRouteProbeAck(rawProbeAck.nonce); + return; + } + + final mediaSwarmRequest = MediaSwarmRequest.tryParseBinary(payload); + if (mediaSwarmRequest != null) { + _handleIncomingMediaSwarmRequest(mediaSwarmRequest); + return; + } + + final mediaSwarmAvailability = MediaSwarmAvailability.tryParseBinary( + payload, + ); + if (mediaSwarmAvailability != null) { + _handleIncomingMediaSwarmAvailability(mediaSwarmAvailability); + return; + } + + final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload); + if (voiceFetchRequest != null) { + debugPrint( + '🎙️ [AppProvider] Incoming voice fetch request: session=${voiceFetchRequest.sessionId} want=${voiceFetchRequest.want} requester=${voiceFetchRequest.requesterKey6}', + ); + final requester = _resolveVoiceFetchRequester(voiceFetchRequest); + if (requester == null) { + debugPrint( + '⚠️ [AppProvider] Voice fetch requester contact not found (binary)', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.', + level: 'warning', + ); + return; + } + if (requester.routeHopCount > _maxDirectPayloadHops) { + debugPrint( + '⚠️ [AppProvider] Voice fetch requester too far: ${requester.routeHopCount} hops', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch voice for ${requester.advName}: message is too far (${requester.routeHopCount} hops, max $_maxDirectPayloadHops).', + level: 'warning', + ); + return; + } + unawaited(() async { + final served = await voiceProvider.serveSessionTo( + sessionId: voiceFetchRequest.sessionId, + requester: requester, + requestedIndices: voiceFetchRequest.want == 'missing' + ? voiceFetchRequest.missingIndices.toSet() + : null, + ); + if (served) { + messagesProvider.recordMediaTransfer( + sessionId: voiceFetchRequest.sessionId, + mediaType: 'voice', + requesterKey6: voiceFetchRequest.requesterKey6, + requesterName: requester.advName, + ); + } + }()); + return; + } + + final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload); + if (imageFetchRequest != null) { + final requester = _resolveImageFetchRequester(imageFetchRequest); + if (requester == null) { + debugPrint( + '⚠️ [AppProvider] Image fetch requester contact not found (binary) ' + 'for session ${imageFetchRequest.sessionId} / ' + '${imageFetchRequest.requesterKey6}', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch image: requester contact is unknown. Add/sync contacts first.', + level: 'warning', + ); + return; + } + if (requester.routeHopCount > _maxDirectPayloadHops) { + debugPrint( + '⚠️ [AppProvider] Image fetch requester too far: ' + '${requester.routeHopCount} hops for session ' + '${imageFetchRequest.sessionId}', + ); + messagesProvider.logSystemMessage( + text: + 'Cannot fetch image for ${requester.advName}: message is too far (${requester.routeHopCount} hops, max $_maxDirectPayloadHops).', + level: 'warning', + ); + return; + } + debugPrint( + '📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} ' + 'to ${requester.advName} via ${requester.routeHopCount} hop(s)', + ); + unawaited(() async { + final served = await imageProvider.serveSessionTo( + sessionId: imageFetchRequest.sessionId, + requester: requester, + requestedIndices: imageFetchRequest.want == 'missing' + ? imageFetchRequest.missingIndices.toSet() + : null, + ); + if (served) { + messagesProvider.recordMediaTransfer( + sessionId: imageFetchRequest.sessionId, + mediaType: 'image', + requesterKey6: imageFetchRequest.requesterKey6, + requesterName: requester.advName, + ); + } + }()); + return; + } + + if (_handleIncomingImageBinaryPayload(payload)) { + return; + } + _handleIncomingVoiceBinaryPayload(payload); + }; + + connectionProvider.onChannelDataReceived = + (channelIdx, pathLen, dataType, payload, snrRaw, rssiDbm) { + if (dataType != MeshCoreConstants.dataTypeDev) { + debugPrint( + '📦 [AppProvider] Ignoring channel datagram type ' + '0x${dataType.toRadixString(16).padLeft(4, '0')} on channel $channelIdx', + ); + return; + } + + if (_handleFastGpsPayload(payload)) { + return; + } + if (_handleIncomingImageBinaryPayload( + payload, + allowPreEnvelopeBuffer: true, + )) { + return; + } + if (_handleIncomingVoiceBinaryPayload( + payload, + allowPreEnvelopeBuffer: true, + )) { + return; + } + + debugPrint( + '📦 [AppProvider] Unknown developer channel payload on channel ' + '$channelIdx: ${payload.isNotEmpty ? payload.first : -1}', + ); + }; + + connectionProvider.onControlDataReceived = + (payload, snrRaw, rssiDbm, pathLen) { + _handleControlDataDiscovery( + payload: payload, + snrRaw: snrRaw, + rssiDbm: rssiDbm, + pathLen: pathLen, + ); + }; + + connectionProvider.onStatusResponse = (publicKeyPrefix, statusData) { + final parsed = _tryParseRepeaterStatus(statusData); + if (parsed == null) { + return; + } + contactsProvider.updatePendingAdvertStatusByPrefix( + publicKeyPrefix, + batteryMv: parsed.batteryMv, + queueLen: parsed.queueLen, + lastRssi: parsed.lastRssi, + lastSnrRaw: parsed.lastSnrRaw, + uptimeSecs: parsed.uptimeSecs, + ); + }; + + // When a contact's routing path is updated in the mesh network + connectionProvider.onPathUpdated = (publicKey) { + _pendingDeliveredRouteRefreshContacts.add(_publicKeyHex(publicKey)); + debugPrint( + '🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...', + ); + if (connectionProvider.deviceInfo.isConnected) { + unawaited(connectionProvider.getContact(publicKey)); + } + }; + + // When an advertisement is received (PUSH_CODE_ADVERT 0x80) + // + // Official app flow: + // 1. importReceivedAdvert(key) — import advert into firmware contact table + // 2. getContact(key) — check if contact existed before (for new-notification) + // 3. syncContact(key) — fetch from device + upsert into DB + // 4. getContact(key) — get fresh contact with name/type + // 5. upsert into discovered_contacts + // 6. if was new → notify with name + type + connectionProvider.onAdvertReceived = (publicKey) { + debugPrint( + '📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...', + ); + unawaited(_retainAdvertRxPath(publicKey)); + unawaited(_handlePushAdvert(publicKey)); + }; + + // When firmware deletes a contact due to contacts table overflow (PUSH_CODE_CONTACT_DELETED 0x8F) + connectionProvider.onContactDeleted = (publicKey) { + final keyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final contact = contactsProvider.findContactByKey(publicKey); + final name = contact?.advName ?? keyHex.substring(0, 12); + debugPrint('⚠️ [AppProvider] Contact deleted by firmware: $name'); + contactsProvider.removeContact(keyHex); + messagesProvider.logSystemMessage( + text: 'Contact "$name" was removed — device contacts table is full', + level: 'warning', + ); + }; + + // When firmware reports contacts storage is full (PUSH_CODE_CONTACTS_FULL 0x90) + connectionProvider.onContactsFull = () { + debugPrint('⚠️ [AppProvider] Contacts storage full'); + messagesProvider.logSystemMessage( + text: + 'Device contacts storage is full. New contacts will overwrite old ones.', + level: 'warning', + ); + }; + + // When a message is sent (RESP_CODE_SENT received) + connectionProvider + .onMessageSent = (messageId, expectedAckTag, suggestedTimeoutMs) { + debugPrint( + '📤 [AppProvider] Message sent - Message ID: $messageId, ACK tag: $expectedAckTag', + ); + messagesProvider.markMessageSent( + messageId, + expectedAckTag, + suggestedTimeoutMs, + ); + }; + + // When a message is delivered (PUSH_CODE_SEND_CONFIRMED received) + connectionProvider.onMessageDelivered = (ackCode, roundTripTimeMs) { + debugPrint( + '✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms', + ); + messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs); + }; + + // When an echo is detected for a public channel message (PUSH_CODE_LOG_RX_DATA matched) + connectionProvider + .onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) { + debugPrint( + '🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount', + ); + messagesProvider.handleMessageEcho( + messageId, + echoCount, + snrRaw, + rssiDbm, + pathBytes: _latestChannelEchoPathBytes(), + ); + }; + + connectionProvider.prepareDirectMessageSendCallback = + ({required messageId, required contact, required retryAttempt}) async { + return _prepareDirectMessageSend( + messageId: messageId, + contact: contact, + retryAttempt: retryAttempt, + ); + }; + + // Wire up MessagesProvider's sendMessageCallback for retry logic + messagesProvider.sendMessageCallback = + ({ + required contactPublicKey, + required text, + required messageId, + required contact, + retryAttempt = 0, + }) async { + return await connectionProvider.sendTextMessage( + contactPublicKey: contactPublicKey, + text: text, + messageId: messageId, + contact: contact, + retryAttempt: retryAttempt, + ); + }; + connectionProvider.resolveContactForDmCallback = (contactPublicKey) { + return contactsProvider.findContactByKey(contactPublicKey); + }; + // Reset path before the last retry attempt to force flood mode + messagesProvider.resetPathBeforeLastRetryCallback = (contact) async { + if (connectionProvider.deviceInfo.isConnected) { + debugPrint( + '🔄 [AppProvider] Resetting path for ${contact.advName} before last retry (flood fallback)', + ); + await connectionProvider.resetPath(contact.publicKey); + } + }; + + messagesProvider.onFinalRouterFallbackCallback = + ({required messageId, required contact, required message}) async { + return _sendWithFinalNearestRouterFallback( + messageId: messageId, + contact: contact, + message: message, + ); + }; + messagesProvider.onFinalDirectMessageFailureCallback = + ({required messageId, required contact, required message}) async { + await _handleDirectMessageFinalFailure( + messageId: messageId, + contact: contact, + ); + }; + messagesProvider.onDirectMessageDeliveredCallback = + ({ + required messageId, + required contact, + required message, + required roundTripTimeMs, + }) { + _handleDirectMessageDelivered( + messageId: messageId, + contact: contact, + roundTripTimeMs: roundTripTimeMs, + ); + }; + messagesProvider.onManualRetryPreparedCallback = (messageId) { + _directMessageRouteSessions.remove(messageId); + }; + } + + Future _prepareDirectMessageSend({ + required String messageId, + required Contact contact, + required int retryAttempt, + }) async { + final latestContact = + contactsProvider.findContactByKey(contact.publicKey) ?? contact; + var session = _directMessageRouteSessions[messageId]; + if (session == null) { + final manualSelection = await _pathHistoryService + .getManualSelectionForContact(latestContact); + final selection = + manualSelection ?? + await _pathHistoryService.getSelectionForContact(latestContact); + session = _DirectMessageRouteSession( + currentSelection: selection, + originalRoute: ContactRouteCodec.fromContact(latestContact), + routerFallbackAttempted: false, + ); + } + + if (!session.routerFallbackAttempted) { + final selection = await _resolveDirectMessageSelectionForRetry( + latestContact, + retryAttempt: retryAttempt, + fallbackSelection: session.currentSelection, + ); + session = session.copyWith(currentSelection: selection); + } + _directMessageRouteSessions[messageId] = session; + + await _applyPathSelection( + latestContact, + session.currentSelection, + messageId: messageId, + routerFallbackAttempted: session.routerFallbackAttempted, + ); + return contactsProvider.findContactByKey(contact.publicKey) ?? + latestContact; + } + + Future _resolveDirectMessageSelectionForRetry( + Contact contact, { + required int retryAttempt, + required PathSelection fallbackSelection, + }) async { + if (retryAttempt == 2) { + return PathSelection.flood(); + } + + if (retryAttempt < 2 && + !fallbackSelection.hasDirectPath && + contact.routeHasPath && + contact.routeHopCount > 0) { + return _pathHistoryService.getSelectionForContact(contact); + } + + return fallbackSelection; + } + + Future _applyPathSelection( + Contact contact, + PathSelection selection, { + required String messageId, + required bool routerFallbackAttempted, + }) async { + final previousRoute = ContactRouteCodec.fromContact(contact); + + try { + if (selection.usesFlood) { + contactsProvider.resetContactRouteLocal(contact.publicKey); + if (connectionProvider.deviceInfo.isConnected) { + await connectionProvider.resetPath(contact.publicKey); + } + } else { + final pathDescriptor = + ((selection.hashSize - 1) << 6) | (selection.hopCount & 0x3F); + final signedDescriptor = ContactRouteCodec.toSignedDescriptor( + pathDescriptor, + ); + final paddedPathBytes = Uint8List(ContactRouteCodec.maxPathBytes) + ..setRange(0, selection.pathBytes.length, selection.pathBytes); + + contactsProvider.setContactRouteLocal( + contact.publicKey, + signedEncodedPathLen: signedDescriptor, + paddedPathBytes: paddedPathBytes, + ); + if (connectionProvider.deviceInfo.isConnected) { + await connectionProvider.setContactRoute( + contact, + signedEncodedPathLen: signedDescriptor, + paddedPathBytes: paddedPathBytes, + ); + } + } + } catch (error) { + _restoreRouteLocal(contact.publicKey, previousRoute); + rethrow; + } + + messagesProvider.updateMessageRouteSelection( + messageId, + selection, + routerFallbackAttempted: routerFallbackAttempted, + ); + } + + void _restoreRouteLocal(Uint8List publicKey, ParsedContactRoute? route) { + if (route == null) { + contactsProvider.resetContactRouteLocal(publicKey); + return; + } + + contactsProvider.setContactRouteLocal( + publicKey, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + ); + } + + Future _restoreRouteOnDevice( + Contact contact, + ParsedContactRoute? route, + ) async { + _restoreRouteLocal(contact.publicKey, route); + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + + if (route == null) { + await connectionProvider.resetPath(contact.publicKey); + return; + } + + await connectionProvider.setContactRoute( + contact, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + ); + } + + PathSelection _buildNearestRouterSelection(Contact repeater, int hashSize) { + return PathSelection( + mode: PathSelectionMode.nearestRouter, + pathBytes: Uint8List.fromList(repeater.publicKey.sublist(0, hashSize)), + hopCount: 1, + hashSize: hashSize, + relayName: repeater.advName, + relayKey6: _key6(repeater.publicKey), + ); + } + + Future _sendWithFinalNearestRouterFallback({ + required String messageId, + required Contact contact, + required Message message, + }) async { + if (!_nearestRelayFallbackEnabled) { + return false; + } + + final latestContact = + contactsProvider.findContactByKey(contact.publicKey) ?? contact; + final manualSelection = await _pathHistoryService + .getManualSelectionForContact(latestContact); + final session = + _directMessageRouteSessions[messageId] ?? + _DirectMessageRouteSession( + currentSelection: + manualSelection ?? + await _pathHistoryService.getSelectionForContact(latestContact), + originalRoute: ContactRouteCodec.fromContact(latestContact), + routerFallbackAttempted: false, + ); + + final repeater = _nearestRouterSelector.select( + senderPosition: locationTrackingService.currentPosition, + repeaters: contactsProvider.repeaters, + recipient: latestContact, + ); + if (repeater == null) { + return false; + } + + final routeHashSize = await RouteHashPreferences.getHashSize(); + final fallbackSelection = _buildNearestRouterSelection( + repeater, + routeHashSize, + ); + _directMessageRouteSessions[messageId] = session.copyWith( + currentSelection: fallbackSelection, + routerFallbackAttempted: true, + ); + messagesProvider.updateMessageRouteSelection( + messageId, + fallbackSelection, + routerFallbackAttempted: true, + ); + + return connectionProvider.sendTextMessage( + contactPublicKey: latestContact.publicKey, + text: message.text, + messageId: messageId, + contact: latestContact, + retryAttempt: message.retryAttempt + 1, + ); + } + + void _handleDirectMessageDelivered({ + required String messageId, + required Contact contact, + required int roundTripTimeMs, + }) { + final session = _directMessageRouteSessions.remove(messageId); + if (session == null) { + return; + } + + if (session.currentSelection.usesFlood || + session.currentSelection.mode == PathSelectionMode.nearestRouter) { + messagesProvider.queueDeliveredMessageRouteRefresh(messageId, contact); + } + } + + Future _handleDirectMessageFinalFailure({ + required String messageId, + required Contact contact, + }) async { + final latestContact = + contactsProvider.findContactByKey(contact.publicKey) ?? contact; + final session = _directMessageRouteSessions.remove(messageId); + if (session != null) { + if (session.routerFallbackAttempted) { + await _restoreRouteOnDevice(latestContact, session.originalRoute); + } + } + + if (_clearPathOnMaxRetry) { + contactsProvider.resetContactRouteLocal(latestContact.publicKey); + if (connectionProvider.deviceInfo.isConnected) { + await connectionProvider.resetPath(latestContact.publicKey); + if (connectionProvider.deviceInfo.isConnected) { + await connectionProvider.getContact(latestContact.publicKey); + } + } + } + } + + String _key6(Uint8List publicKey) { + final bytes = publicKey.sublist(0, math.min(6, publicKey.length)); + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + } + + String _publicKeyHex(Uint8List publicKey) { + return publicKey + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + } + + /// Estimate contact location from the received message path. + /// + /// When we receive a message, the path bytes describe how it traveled: + /// Sender → first_hop (near sender) → ... → last_hop (near us) → Us + /// + /// The FIRST hop in the received path is the repeater nearest to the sender. + /// We don't have the actual RSSI between sender and that repeater, so we + /// use a conservative estimate to place them within typical LoRa range. + void _estimateContactLocationFromReceivedPath({ + required Contact contact, + required List receivedPathBytes, + }) { + if (receivedPathBytes.isEmpty) return; + + // Infer hash size from the contact's known path encoding, or default to 1 + final hashSize = contact.routeHasPath ? contact.routeHashSize : 1; + if (receivedPathBytes.length < hashSize) return; + + // First hop in received path = repeater nearest to sender + final firstHopHash = receivedPathBytes + .sublist(0, hashSize) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + + Contact? nearSenderRepeater; + for (final c in contactsProvider.contacts) { + if (c.publicKeyHex.toLowerCase().startsWith(firstHopHash) && + c.displayLocation != null) { + nearSenderRepeater = c; + break; + } + } + if (nearSenderRepeater == null) return; + + // We don't have the RSSI between sender and their repeater. + // Use -70 dBm as conservative estimate (~300m in open terrain). + const int estimatedRssi = -70; + + contactsProvider.addRssiObservation( + contactPublicKeyHex: contact.publicKeyHex, + contactPublicKey: contact.publicKey, + observation: RssiObservation( + repeaterLocation: nearSenderRepeater.displayLocation!, + rssiDbm: estimatedRssi, + observedAt: DateTime.now(), + ), + ); + } + + Future _retainAdvertRxPath(Uint8List publicKey) async { + final decoded = _findBestMatchingAdvertRxRoute(publicKey); + if (decoded == null || decoded.pathBytes.isEmpty) { + return; + } + + final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes( + decoded.pathBytes, + hashSize: decoded.hashSize, + ); + final reversedHopHashes = LogRxRouteDecoder.splitHopHashes( + reversedPathBytes, + hashSize: decoded.hashSize, + ); + + final parsedRoute = ContactRouteCodec.parse( + reversedHopHashes.join(','), + expectedHashSize: decoded.hashSize, + ); + contactsProvider.retainReceivedRoute( + publicKey, + signedEncodedPathLen: parsedRoute.signedEncodedPathLen, + paddedPathBytes: parsedRoute.paddedPathBytes, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + } + + /// Handle pushAdvert (0x80) — matches the official MeshCore app flow: + /// + /// 1. importReceivedAdvert(key) — import advert into firmware contact table + /// 2. Check if contact was already known locally (for new-notification) + /// 3. getContact(key) — fetch full contact from device (now stored after import) + /// → flows through onContactReceivedDetailed which upserts + notifies + Future _handlePushAdvert(Uint8List publicKey) async { + // Step 1: Import the advert into firmware's contact table. + // This tells the firmware to store the contact from its received-advert + // buffer, making subsequent getContact return name/type/location. + await connectionProvider.importReceivedAdvert(publicKey); + + // Step 2: Check if this was an already-known contact before sync + final wasKnown = contactsProvider.findContactByKey(publicKey) != null; + + // Step 3: Fetch the full contact from device (import made it available). + // This flows through onContactReceivedDetailed, which handles: + // - upserting into contacts or pending adverts + // - showing discovery notification for new contacts + if (connectionProvider.deviceInfo.isConnected) { + await connectionProvider.getContact(publicKey); + } + + // For existing contacts, the getContact above already updated them. + // For new contacts from adverts, onContactReceivedDetailed (source=advert) + // already added to pending adverts and showed notification. + // Nothing else to do — the callback pipeline handles everything. + if (wasKnown) { + debugPrint(' [pushAdvert] Existing contact refreshed'); + } + } + + Future _requestRepeaterStatus(Uint8List publicKey) async { + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + final keyHex = publicKey + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + final now = DateTime.now(); + final lastRequestAt = _recentRepeaterStatusRequests[keyHex]; + if (lastRequestAt != null && + now.difference(lastRequestAt) < const Duration(minutes: 2)) { + return; + } + _recentRepeaterStatusRequests[keyHex] = now; + try { + await connectionProvider.requestStatus(publicKey); + } catch (_) { + // Ignore unsupported or unreachable repeaters. + } + } + + Future _maybeRequestRepeaterOwnerInfo(Uint8List publicKey) async { + final pendingAdvert = contactsProvider.pendingAdvertByKey(publicKey); + if (pendingAdvert == null || + pendingAdvert.typeValue != ContactType.repeater.value || + pendingAdvert.advName?.trim().isNotEmpty == true || + !connectionProvider.deviceInfo.isConnected) { + return; + } + + final keyHex = pendingAdvert.publicKeyHex; + final now = DateTime.now(); + final lastRequestAt = _recentRepeaterOwnerInfoRequests[keyHex]; + if (lastRequestAt != null && + now.difference(lastRequestAt) < _repeaterOwnerInfoRequestCooldown) { + return; + } + + Contact? existingContact; + for (final contact in contactsProvider.contacts) { + if (contact.publicKeyHex == keyHex) { + existingContact = contact; + break; + } + } + + var temporaryContactAdded = false; + final requestContact = + existingContact ?? _temporaryRepeaterContactForOwnerInfo(pendingAdvert); + if (requestContact == null || !requestContact.routeHasPath) { + return; + } + + _recentRepeaterOwnerInfoRequests[keyHex] = now; + + if (existingContact == null) { + connectionProvider.clearError(); + await connectionProvider.addOrUpdateContact(requestContact); + if (connectionProvider.error != null) { + return; + } + connectionProvider.clearError(); + temporaryContactAdded = true; + } + + try { + final ticket = await connectionProvider.sendAnonRequest( + contactPublicKey: publicKey, + requestData: _buildRepeaterOwnerRequest(requestContact), + ); + if (ticket == null) { + return; + } + _pendingRepeaterOwnerRequests[ticket.tag] = _PendingRepeaterOwnerRequest( + publicKey: Uint8List.fromList(publicKey), + ); + Future.delayed( + Duration(milliseconds: ticket.suggestedTimeoutMs + 1500), + () => _pendingRepeaterOwnerRequests.remove(ticket.tag), + ); + } catch (_) { + // Ignore unsupported or unreachable repeaters. + } finally { + if (temporaryContactAdded) { + Future.delayed(const Duration(milliseconds: 250), () async { + await connectionProvider.removeContact(publicKey); + connectionProvider.clearError(); + }); + } + } + } + + Contact? _temporaryRepeaterContactForOwnerInfo(PendingAdvert advert) { + final signedEncodedPathLen = advert.signedEncodedPathLen; + if (signedEncodedPathLen == null) { + return null; + } + + final advName = advert.advName?.trim(); + final lastAdvert = + advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000); + return Contact( + publicKey: Uint8List.fromList(advert.publicKey), + type: ContactType.repeater, + flags: advert.flags ?? 0, + outPathLen: signedEncodedPathLen, + outPath: advert.paddedPathBytes == null + ? Uint8List(ContactRouteCodec.maxPathBytes) + : Uint8List.fromList(advert.paddedPathBytes!), + advName: advName?.isNotEmpty == true ? advName! : advert.shortDisplayKey, + lastAdvert: lastAdvert, + advLat: advert.advLat ?? 0, + advLon: advert.advLon ?? 0, + lastMod: lastAdvert, + ); + } + + Uint8List _buildRepeaterOwnerRequest(Contact contact) { + final replyPathDescriptor = contact.routeEncodedPathLen; + final replyPathBytes = contact.routeHopCount > 0 + ? Uint8List.fromList( + LogRxRouteDecoder.reverseHopBytes( + contact.routePathBytes, + hashSize: contact.routeHashSize, + ), + ) + : Uint8List(0); + return Uint8List.fromList([ + _anonReqTypeOwner, + replyPathDescriptor, + ...replyPathBytes, + ]); + } + + bool _handlePendingRepeaterOwnerResponse(int tag, Uint8List responseData) { + final request = _pendingRepeaterOwnerRequests.remove(tag); + if (request == null) { + return false; + } + + final ownerName = _tryParseRepeaterOwnerName(responseData); + if (ownerName == null || ownerName.isEmpty) { + return true; + } + + final existing = contactsProvider.pendingAdvertByKey(request.publicKey); + contactsProvider.addOrUpdatePendingAdvertMetadata( + publicKey: request.publicKey, + typeValue: existing?.typeValue ?? ContactType.repeater.value, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + advName: ownerName, + ); + return true; + } + + String? _tryParseRepeaterOwnerName(Uint8List responseData) { + if (responseData.length <= 4) { + return null; + } + + try { + final payload = utf8 + .decode(responseData.sublist(4), allowMalformed: true) + .trim(); + if (payload.isEmpty) { + return null; + } + final firstLine = payload.split(RegExp(r'[\r\n]+')).first.trim(); + return firstLine.isEmpty ? null : firstLine; + } catch (_) { + return null; + } + } + + _ParsedRepeaterStatus? _tryParseRepeaterStatus(Uint8List statusData) { + if (statusData.length < 52) { + return null; + } + + try { + final data = ByteData.sublistView(statusData); + var offset = 0; + final batteryMv = data.getUint16(offset, Endian.little); + offset += 2; + final queueLen = data.getUint16(offset, Endian.little); + offset += 2; + offset += 2; // noiseFloor + final lastRssi = data.getInt16(offset, Endian.little); + offset += 2; + offset += 4; // packetsRecv + offset += 4; // packetsSent + offset += 4; // txAirSecs + final uptimeSecs = data.getUint32(offset, Endian.little); + offset += 4; + offset += 4; // floodTx + offset += 4; // directTx + offset += 4; // floodRx + offset += 4; // directRx + offset += 2; // errEvents + final lastSnrRaw = data.getInt16(offset, Endian.little); + + return _ParsedRepeaterStatus( + batteryMv: batteryMv, + queueLen: queueLen, + lastRssi: lastRssi, + lastSnrRaw: lastSnrRaw, + uptimeSecs: uptimeSecs, + ); + } catch (_) { + return null; + } + } + + _ParsedRawAdvert? _tryParseRawAdvert(Uint8List rawPayload) { + if (rawPayload.length < 103) { + return null; + } + + try { + final reader = BufferReader(rawPayload); + final header = reader.readByte(); + final routeType = header & 0x03; + final payloadType = (header >> 2) & 0x0F; + if (payloadType != _rawPayloadTypeAdvert) { + return null; + } + + if (routeType == _routeTransportFlood || + routeType == _routeTransportDirect) { + if (reader.remainingBytesCount < 4) { + return null; + } + reader.skip(4); + } + + if (reader.remainingBytesCount < 1) { + return null; + } + final pathByteLen = reader.readByte(); + if (reader.remainingBytesCount < pathByteLen + 101) { + return null; + } + final pathBytes = reader.readBytes(pathByteLen); + final publicKey = reader.readBytes(32); + final timestamp = reader.readInt32LE(); + reader.skip(64); + final flags = reader.readByte(); + final typeValue = flags & 0x0F; + final hasLocation = (flags & 0x10) != 0; + final hasName = (flags & 0x80) != 0; + + int? advLat; + int? advLon; + if (hasLocation) { + if (reader.remainingBytesCount < 8) { + return null; + } + advLat = reader.readInt32LE(); + advLon = reader.readInt32LE(); + } + + String? advName; + if (hasName && reader.remainingBytesCount > 0) { + final decodedName = utf8 + .decode(reader.readRemainingBytes(), allowMalformed: true) + .trim(); + if (decodedName.isNotEmpty) { + advName = decodedName; + } + } + + int? signedEncodedPathLen; + Uint8List? paddedPathBytes; + if (pathBytes.isNotEmpty) { + final hashSize = LogRxRouteDecoder.inferHashSize(pathBytes); + final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes( + pathBytes, + hashSize: hashSize, + ); + final padded = Uint8List(ContactRouteCodec.maxPathBytes) + ..setRange(0, reversedPathBytes.length, reversedPathBytes); + final encodedPathLen = + ((hashSize - 1) << 6) | + ((reversedPathBytes.length ~/ hashSize) & 0x3F); + signedEncodedPathLen = ContactRouteCodec.toSignedDescriptor( + encodedPathLen, + ); + paddedPathBytes = padded; + } + + return _ParsedRawAdvert( + publicKey: publicKey, + advName: advName, + typeValue: typeValue, + flags: flags, + lastAdvert: timestamp, + advLat: advLat, + advLon: advLon, + signedEncodedPathLen: signedEncodedPathLen, + paddedPathBytes: paddedPathBytes, + ); + } catch (_) { + return null; + } + } + + /// Initialize the app (load contacts, sync time, etc.) + Future initialize() async { + if (!connectionProvider.deviceInfo.isConnected) return; + + try { + _isReconnectSyncInProgress = true; + _hasCompletedConnectionBootstrap = false; + await contactsProvider.prepareForDeviceContactSync( + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + channelsProvider.prepareForDeviceSync(); + + // Note: Device clock is automatically synced during connection in MeshCoreBleService + // No need to sync it again here + + // Get battery and storage information + await connectionProvider.getBatteryAndStorage(); + + // Load contacts (waits for device to finish sending all contacts). + // The device list is authoritative: contacts it no longer has are + // deleted locally once a complete list was received. + final contactListComplete = await connectionProvider.getContacts(); + contactsProvider.finalizeDeviceContactSync( + deviceListComplete: contactListComplete, + ); + + // Sync all channels so slot assignment and channel state mirror the device. + final channelsToSync = connectionProvider.deviceInfo.maxChannels; + debugPrint( + '📻 [AppProvider] Syncing channels (max: ${channelsToSync ?? 40})...', + ); + await connectionProvider.syncChannels(maxChannels: channelsToSync); + debugPrint('✅ [AppProvider] Channel sync complete'); + + // Automatically login to all saved rooms + await _autoLoginToRooms(); + + // FALLBACK: Sync messages once after connection to catch any missed push notifications + // This handles the case where messages arrived while the app was disconnected + debugPrint( + '🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)', + ); + final initialMessageCount = await messagesProvider + .withReceivedNotificationsSuppressed( + () => connectionProvider.syncAllMessages(force: true), + ); + debugPrint( + '📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)', + ); + + // Note: Future messages are synced automatically via PUSH_CODE_MSG_WAITING events + + // Start location tracking AFTER all initialization is complete + debugPrint( + '📍 [AppProvider] Starting location tracking after successful initialization', + ); + await _startLocationTracking(); + await refreshChannelLocationSharingState(); + + // Sync drawing messages with DrawingProvider + // This restores any drawings that may be missing from storage + debugPrint( + '🎨 [AppProvider] Syncing drawing messages with DrawingProvider...', + ); + messagesProvider.syncDrawingsWithProvider(drawingProvider); + + _hasCompletedConnectionBootstrap = true; + _wasDeviceConnected = connectionProvider.deviceInfo.isConnected; + await _flushDeferredAutomaticMessageSync(); + notifyListeners(); + } catch (e) { + debugPrint('Initialization error: $e'); + } finally { + _isReconnectSyncInProgress = false; + } + } + + Future _syncAfterReconnect({bool started = false}) async { + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + + if (!started) { + if (_isReconnectSyncInProgress) { + return; + } + _isReconnectSyncInProgress = true; + } + try { + debugPrint( + '🔄 [AppProvider] Device reconnected - syncing contacts and missed messages', + ); + + await contactsProvider.prepareForDeviceContactSync( + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + channelsProvider.prepareForDeviceSync(); + final contactListComplete = await connectionProvider.getContacts(); + contactsProvider.finalizeDeviceContactSync( + deviceListComplete: contactListComplete, + ); + await connectionProvider.syncChannels( + maxChannels: connectionProvider.deviceInfo.maxChannels, + ); + await refreshChannelLocationSharingState(); + + final messageCount = await messagesProvider + .withReceivedNotificationsSuppressed( + () => connectionProvider.syncAllMessages(force: true), + ); + debugPrint( + '📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)', + ); + } catch (e) { + debugPrint('❌ [AppProvider] Reconnect sync error: $e'); + } finally { + _hasCompletedConnectionBootstrap = + connectionProvider.deviceInfo.isConnected; + _isReconnectSyncInProgress = false; + await _flushDeferredAutomaticMessageSync(); + } + } + + /// Automatically login to all rooms with saved passwords on cold connect + Future _autoLoginToRooms() async { + if (!connectionProvider.deviceInfo.isConnected) return; + + try { + // Get all room contacts (excluding Public Channel) + final rooms = contactsProvider.rooms + .where((room) => !room.isPublicChannel) + .toList(); + + if (rooms.isEmpty) { + debugPrint('📂 [AppProvider] No rooms found to auto-login'); + return; + } + + debugPrint( + '📂 [AppProvider] Found ${rooms.length} room(s), attempting auto-login...', + ); + + final prefs = await SharedPreferences.getInstance(); + + for (final room in rooms) { + try { + // Load saved password for this room + final roomKey = 'room_password_${room.publicKeyHex}'; + final savedPassword = prefs.getString(roomKey) ?? 'hello'; + + debugPrint( + '🔑 [AppProvider] Auto-logging into room: ${room.advName}', + ); + + // Set up one-time callbacks for this room login + await _loginToRoomWithCallback(room, savedPassword); + + // Small delay between logins to avoid overwhelming the device + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + debugPrint( + '❌ [AppProvider] Failed to auto-login to ${room.advName}: $e', + ); + } + } + } catch (e) { + debugPrint('❌ [AppProvider] Auto-login error: $e'); + } + } + + /// Login to a specific room with callback handling + Future _loginToRoomWithCallback(Contact room, String password) async { + // Create a completer to wait for login result + final completer = Completer(); + + // Store original callbacks + final originalOnSuccess = connectionProvider.onLoginSuccess; + final originalOnFail = connectionProvider.onLoginFail; + + // Set up temporary callbacks + connectionProvider + .onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async { + // Restore original callbacks + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + debugPrint('✅ [AppProvider] Auto-login successful for ${room.advName}'); + debugPrint( + '📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING', + ); + + completer.complete(true); + }; + + connectionProvider.onLoginFail = (publicKeyPrefix) { + // Restore original callbacks + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + debugPrint( + '❌ [AppProvider] Auto-login failed for ${room.advName} (incorrect password)', + ); + completer.complete(false); + }; + + try { + // Send login request + await connectionProvider.loginToRoom( + roomPublicKey: room.publicKey, + password: password, + ); + + // Wait for login result with timeout + await completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + // Restore callbacks on timeout + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + debugPrint('⏱️ [AppProvider] Auto-login timeout for ${room.advName}'); + return false; + }, + ); + } catch (e) { + // Restore callbacks on error + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + debugPrint( + '❌ [AppProvider] Error during auto-login to ${room.advName}: $e', + ); + } + } + + Contact? _resolveContactByPrefixHex(String prefixHex) { + if (prefixHex.length != 12) return null; + return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase()); + } + + void setFastLocationUiActive(bool isActive) { + if (_fastLocationScreenActive == isActive) return; + _fastLocationScreenActive = isActive; + locationTrackingService.setFastLocationActiveUse(isActive); + } + + Future reloadProfileScopedSettings() async { + await Future.wait([ + _loadMapEnabled(), + _loadContactsEnabled(), + _loadContactsSectionVisibility(), + _loadSensorsEnabled(), + _loadVoiceSilenceTrimmingEnabled(), + _loadVoiceBandPassFilterEnabled(), + _loadVoiceCompressorEnabled(), + _loadVoiceLimiterEnabled(), + _loadVoiceAutoGainEnabled(), + _loadVoiceEchoCancellationEnabled(), + _loadVoiceNoiseSuppressionEnabled(), + _loadMessageFontScale(), + _loadMessagingRouteSettings(), + locationTrackingService.loadSettings(), + ]); + } + + Future refreshChannelLocationSharingState() async { + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + + final vars = await connectionProvider.getCustomVars(); + final hardwareSupported = _supportsHardwareChannelLocationSharing(vars); + final rawChannelIdx = hardwareSupported + ? int.tryParse(vars['fast_gps_channel'] ?? '') + : null; + final normalizedChannelIdx = rawChannelIdx != null && rawChannelIdx > 0 + ? rawChannelIdx + : null; + + if (_hardwareChannelLocationSharingSupported == hardwareSupported && + _hardwareChannelLocationSharingChannelIdx == normalizedChannelIdx) { + return; + } + + _hardwareChannelLocationSharingSupported = hardwareSupported; + _hardwareChannelLocationSharingChannelIdx = normalizedChannelIdx; + notifyListeners(); + } + + ChannelLocationSharingMode? channelLocationSharingModeForChannel( + int channelIdx, + ) { + if (channelIdx <= 0) { + return null; + } + if (_hardwareChannelLocationSharingChannelIdx == channelIdx) { + return ChannelLocationSharingMode.hardware; + } + if (_isAppFallbackLocationSharingActiveForChannel(channelIdx)) { + return ChannelLocationSharingMode.appFallback; + } + return null; + } + + void notifyChannelLocationSharingChanged() { + notifyListeners(); + } + + Future _clearAppFallbackChannelLocationSharingState() async { + await locationTrackingService.updateFastLocationChannelIdx(null); + await locationTrackingService.setFastLocationUpdatesEnabled(false); + notifyListeners(); + } + + bool _isAppFallbackLocationSharingActiveForChannel(int channelIdx) { + return locationTrackingService.fastLocationUpdatesEnabled && + locationTrackingService.fastLocationChannelIdx == channelIdx; + } + + bool _supportsHardwareChannelLocationSharing(Map vars) { + return vars.containsKey('gps') && vars.containsKey('fast_gps_channel'); + } + + int? _hardwareChannelLocationSharingIdxFromVars(Map vars) { + final rawChannelIdx = int.tryParse(vars['fast_gps_channel'] ?? ''); + return rawChannelIdx != null && rawChannelIdx > 0 ? rawChannelIdx : null; + } + + Future _setDeviceCustomVarOrThrow(String key, String value) async { + connectionProvider.clearError(); + await connectionProvider.setCustomVar(key, value); + final error = connectionProvider.error; + if (error != null) { + connectionProvider.clearError(); + throw StateError(error); + } + } + + /// Reads the radio's fast-GPS region scope, or `null` when the firmware does + /// not expose region scoping (older builds only know `fast_gps_channel`). + Future getFastGpsRegionState() async { + if (!connectionProvider.deviceInfo.isConnected) { + return null; + } + final vars = await connectionProvider.getCustomVars(); + final labelsRaw = vars['fast_gps_regions']; + if (labelsRaw == null || labelsRaw.isEmpty) { + return null; + } + final labels = labelsRaw.split(','); + final index = int.tryParse(vars['fast_gps_region'] ?? '0') ?? 0; + return FastGpsRegionState( + labels: labels, + selectedIndex: (index >= 0 && index < labels.length) ? index : 0, + ); + } + + /// Sets the radio's fast-GPS region scope by index (0 = unscoped). + Future setFastGpsRegion(int index) async { + if (!connectionProvider.deviceInfo.isConnected) { + throw StateError('Connect to a device first'); + } + await _setDeviceCustomVarOrThrow('fast_gps_region', index.toString()); + notifyListeners(); + } + + Future getChannelLocationSharingState( + int channelIdx, + ) async { + if (channelIdx <= 0) { + return const ChannelLocationSharingState( + mode: ChannelLocationSharingMode.appFallback, + isSharing: false, + hardwareSupported: false, + isConnected: false, + ); + } + + if (connectionProvider.deviceInfo.isConnected) { + await refreshChannelLocationSharingState(); + } + + final sharingMode = channelLocationSharingModeForChannel(channelIdx); + + return ChannelLocationSharingState( + mode: + sharingMode ?? + (_hardwareChannelLocationSharingSupported + ? ChannelLocationSharingMode.hardware + : ChannelLocationSharingMode.appFallback), + isSharing: sharingMode != null, + hardwareSupported: _hardwareChannelLocationSharingSupported, + isConnected: connectionProvider.deviceInfo.isConnected, + ); + } + + Future setChannelLocationSharingEnabled( + int channelIdx, + bool enabled, { + AppLocalizations? l10n, + }) async { + if (channelIdx <= 0) { + throw ArgumentError.value( + channelIdx, + 'channelIdx', + 'Location sharing requires a non-public channel', + ); + } + + if (!connectionProvider.deviceInfo.isConnected) { + throw StateError('Connect to a device first'); + } + + final vars = await connectionProvider.getCustomVars(); + final hardwareSupported = _supportsHardwareChannelLocationSharing(vars); + if (enabled) { + if (hardwareSupported) { + await _setDeviceCustomVarOrThrow('gps', '1'); + await _setDeviceCustomVarOrThrow( + 'fast_gps_channel', + channelIdx.toString(), + ); + _hardwareChannelLocationSharingSupported = true; + _hardwareChannelLocationSharingChannelIdx = channelIdx; + await locationTrackingService.updateFastLocationChannelIdx(null); + await locationTrackingService.setFastLocationUpdatesEnabled(false); + await refreshChannelLocationSharingState(); + if (_hardwareChannelLocationSharingChannelIdx != channelIdx) { + throw StateError( + 'Radio location sharing did not enable for this channel', + ); + } + notifyListeners(); + return ChannelLocationSharingResult( + state: const ChannelLocationSharingState( + mode: ChannelLocationSharingMode.hardware, + isSharing: true, + hardwareSupported: true, + isConnected: true, + ), + message: + l10n?.sharingLocationFromRadio ?? + 'Sharing location on this channel from the radio.', + ); + } + + _hardwareChannelLocationSharingSupported = false; + _hardwareChannelLocationSharingChannelIdx = null; + final previousEnabled = + locationTrackingService.fastLocationUpdatesEnabled; + final previousChannelIdx = locationTrackingService.fastLocationChannelIdx; + try { + await locationTrackingService.updateFastLocationChannelIdx(channelIdx); + await locationTrackingService.setFastLocationUpdatesEnabled(true); + if (!locationTrackingService.isTracking) { + final started = await locationTrackingService.startTracking(); + if (!started) { + throw StateError('Phone location tracking could not be started'); + } + } + } catch (e) { + await locationTrackingService.updateFastLocationChannelIdx( + previousChannelIdx, + ); + await locationTrackingService.setFastLocationUpdatesEnabled( + previousEnabled, + ); + rethrow; + } + notifyListeners(); + return ChannelLocationSharingResult( + state: const ChannelLocationSharingState( + mode: ChannelLocationSharingMode.appFallback, + isSharing: true, + hardwareSupported: false, + isConnected: true, + ), + message: + l10n?.sharingLocationFromPhone ?? + 'Sharing location on this channel from the phone.', + ); + } + + final activeHardwareChannelIdx = _hardwareChannelLocationSharingIdxFromVars( + vars, + ); + final shouldAttemptHardwareStop = + hardwareSupported || + _hardwareChannelLocationSharingSupported || + activeHardwareChannelIdx == channelIdx || + _hardwareChannelLocationSharingChannelIdx == channelIdx; + + if (locationTrackingService.fastLocationChannelIdx == channelIdx) { + await locationTrackingService.updateFastLocationChannelIdx(null); + await locationTrackingService.setFastLocationUpdatesEnabled(false); + } + + if (shouldAttemptHardwareStop) { + await _setDeviceCustomVarOrThrow('fast_gps_channel', '-1'); + _hardwareChannelLocationSharingSupported = true; + _hardwareChannelLocationSharingChannelIdx = null; + await refreshChannelLocationSharingState(); + if (_hardwareChannelLocationSharingChannelIdx == channelIdx) { + throw StateError( + 'Radio location sharing is still enabled for this channel', + ); + } + notifyListeners(); + return ChannelLocationSharingResult( + state: const ChannelLocationSharingState( + mode: ChannelLocationSharingMode.hardware, + isSharing: false, + hardwareSupported: true, + isConnected: true, + ), + message: + l10n?.stoppedSharingLocation ?? + 'Stopped sharing location on this channel.', + ); + } + + _hardwareChannelLocationSharingSupported = false; + _hardwareChannelLocationSharingChannelIdx = null; + notifyListeners(); + return ChannelLocationSharingResult( + state: const ChannelLocationSharingState( + mode: ChannelLocationSharingMode.appFallback, + isSharing: false, + hardwareSupported: false, + isConnected: true, + ), + message: + l10n?.stoppedSharingLocation ?? + 'Stopped sharing location on this channel.', + ); + } + + Future _sendFastLocationUpdate({ + required double latitude, + required double longitude, + required int speedKmh, + required String reason, + }) async { + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + + final channelIdx = locationTrackingService.fastLocationChannelIdx; + if (channelIdx == null) { + return; + } + + final hasTargetChannel = contactsProvider.channels.any( + (channel) => + (channel.publicKey.length > 1 ? channel.publicKey[1] : 0) == + channelIdx, + ); + if (!hasTargetChannel) { + debugPrint( + '⚠️ [AppProvider] Fast GPS target channel $channelIdx is unavailable', + ); + return; + } + + final publicKey = connectionProvider.deviceInfo.publicKey; + if (publicKey == null || publicKey.length < 6) { + return; + } + + final senderKey6 = publicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final clampedSpeedKmh = speedKmh.clamp(0, 255); + final packet = FastGpsPacket( + senderKey6: senderKey6, + latitude: latitude, + longitude: longitude, + speedKmh: clampedSpeedKmh, + ); + debugPrint( + '📤 [AppProvider] Fast GPS send ' + 'reason=$reason ' + 'sender=$senderKey6 ' + 'channel=$channelIdx ' + 'lat=$latitude ' + 'lon=$longitude ' + 'speed=${clampedSpeedKmh}km/h', + ); + try { + await connectionProvider.sendChannelData( + channelIdx: channelIdx, + dataType: MeshCoreConstants.dataTypeDev, + payload: packet.encodeBinary(), + ); + debugPrint( + '✅ [AppProvider] Fast GPS sent ' + 'sender=$senderKey6 channel=$channelIdx speed=${speedKmh}km/h', + ); + } catch (e) { + debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e'); + } + } + + Future sendTestFastLocationUpdate() async { + if (!connectionProvider.deviceInfo.isConnected) { + return false; + } + + final position = await locationTrackingService.getCurrentPosition( + timeLimit: const Duration(seconds: 10), + retryCount: 1, + ); + if (position == null) { + return false; + } + + // Geolocator reports ground speed in m/s; the beacon carries km/h (0..255). + final speedMs = position.speed; + final speedKmh = (speedMs.isFinite && speedMs > 0) + ? (speedMs * 3.6).round().clamp(0, 255) + : 0; + await _sendFastLocationUpdate( + latitude: position.latitude, + longitude: position.longitude, + speedKmh: speedKmh, + reason: 'test', + ); + return true; + } + + Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) { + final liveContact = _resolveContactByPrefixHex(request.requesterKey6); + if (liveContact != null) { + return liveContact; + } + + return _resolveRequesterFromSentMessages( + sessionId: request.sessionId, + requesterKey6: request.requesterKey6, + tryParseEnvelope: VoiceEnvelope.tryParseText, + mediaLabel: 'voice', + ); + } + + Contact? _resolveImageFetchRequester(ImageFetchRequest request) { + final liveContact = _resolveContactByPrefixHex(request.requesterKey6); + if (liveContact != null) { + return liveContact; + } + + return _resolveRequesterFromSentMessages( + sessionId: request.sessionId, + requesterKey6: request.requesterKey6, + tryParseEnvelope: ImageEnvelope.tryParse, + mediaLabel: 'image', + ); + } + + Contact? _resolveRequesterFromSentMessages({ + required String sessionId, + required String requesterKey6, + required T? Function(String text) tryParseEnvelope, + required String mediaLabel, + }) { + for (final message in messagesProvider.messages.reversed) { + final envelope = tryParseEnvelope(message.text); + if (envelope == null) { + continue; + } + final envelopeSessionId = switch (envelope) { + VoiceEnvelope voiceEnvelope => voiceEnvelope.sessionId, + ImageEnvelope imageEnvelope => imageEnvelope.sessionId, + _ => null, + }; + if (envelopeSessionId != sessionId) { + continue; + } + final recipientKey = message.recipientPublicKey; + if (recipientKey == null || recipientKey.isEmpty) { + continue; + } + final recipient = contactsProvider.findContactByKey(recipientKey); + if (recipient == null) { + continue; + } + final recipientKey6 = recipient.publicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + if (recipientKey6 != requesterKey6) { + continue; + } + debugPrint( + '${mediaLabel == 'voice' ? '🎙️' : '📷'} [AppProvider] Resolved ' + '$mediaLabel requester from sent message metadata for session ' + '$sessionId: ${recipient.advName}', + ); + return recipient; + } + + return null; + } + + String _mediaSwarmKey(String mediaType, String sessionId) => + '$mediaType:$sessionId'; + + String? _deviceKey6Hex() { + final deviceKey = connectionProvider.deviceInfo.publicKey; + if (deviceKey == null || deviceKey.length < 6) { + return null; + } + return deviceKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + } + + List _availableIndicesForSession(String mediaType, String sessionId) { + return switch (mediaType) { + 'voice' => voiceProvider.availablePacketIndices(sessionId), + 'image' => imageProvider.availableFragmentIndices(sessionId), + _ => const [], + }; + } + + List _matchingAvailableIndices(MediaSwarmRequest request) { + final available = _availableIndicesForSession( + request.mediaType, + request.sessionId, + ); + if (available.isEmpty) return const []; + if (request.requestsAll) return available; + final requested = request.missingIndices.toSet(); + return available.where(requested.contains).toList()..sort(); + } + + List _eligibleSwarmPeers({String? excludeKey6}) { + final ownKey6 = _deviceKey6Hex(); + return contactsProvider.contacts.where((contact) { + if (!contact.routeHasPath || + contact.routeHopCount > _maxDirectPayloadHops || + !contact.routeSupportsLegacyRawTransport || + contact.outPath.isEmpty || + contact.publicKey.length < 6) { + return false; + } + final key6 = contact.publicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + if (key6 == ownKey6 || key6 == excludeKey6) { + return false; + } + return true; + }).toList(); + } + + void _handleIncomingMediaSwarmRequest(MediaSwarmRequest request) { + final ownKey6 = _deviceKey6Hex(); + if (ownKey6 == null || request.requesterKey6 == ownKey6) { + return; + } + + final available = _matchingAvailableIndices(request); + if (available.isEmpty) { + return; + } + + final availability = MediaSwarmAvailability( + mediaType: request.mediaType, + sessionId: request.sessionId, + requesterKey6: request.requesterKey6, + responderKey6: ownKey6, + availableIndices: available, + ); + + debugPrint( + '🌐 [AppProvider] Media swarm availability for ${request.mediaType} ' + '${request.sessionId}: ${available.length} fragment(s)', + ); + final requester = _resolveContactByPrefixHex(request.requesterKey6); + if (requester == null || + !requester.routeHasPath || + requester.routeHopCount > _maxDirectPayloadHops || + requester.outPath.isEmpty) { + return; + } + unawaited( + connectionProvider.sendRawVoicePacket( + contactPath: requester.outPath, + contactPathLen: requester.routeEncodedPathLen, + payload: availability.encodeBinary(), + ), + ); + } + + void _handleIncomingMediaSwarmAvailability( + MediaSwarmAvailability availability, + ) { + final ownKey6 = _deviceKey6Hex(); + if (ownKey6 == null || availability.requesterKey6 != ownKey6) { + return; + } + + final key = _mediaSwarmKey(availability.mediaType, availability.sessionId); + final responses = _pendingMediaSwarmResponses[key]; + if (responses == null) { + return; + } + responses[availability.responderKey6] = availability; + debugPrint( + '🌐 [AppProvider] Media swarm response for ${availability.mediaType} ' + '${availability.sessionId} from ${availability.responderKey6} ' + '(${availability.servesAll ? 'all' : availability.availableIndices.length})', + ); + } + + Future _requestMissingMediaViaSwarm({ + required String mediaType, + required String sessionId, + required List missingIndices, + required String? originalSenderKey6, + }) async { + if (!connectionProvider.deviceInfo.isConnected || missingIndices.isEmpty) { + return false; + } + + final key = _mediaSwarmKey(mediaType, sessionId); + final pending = _pendingMediaSwarmFetches[key]; + if (pending != null) { + return pending; + } + + final requesterKey6 = _deviceKey6Hex(); + if (requesterKey6 == null) { + return false; + } + + final future = () async { + final responses = {}; + _pendingMediaSwarmResponses[key] = responses; + + try { + final request = MediaSwarmRequest( + mediaType: mediaType, + sessionId: sessionId, + requesterKey6: requesterKey6, + missingIndices: missingIndices, + ); + final peers = _eligibleSwarmPeers(excludeKey6: originalSenderKey6); + if (peers.isEmpty) { + return false; + } + debugPrint( + '🌐 [AppProvider] Media swarm request for $mediaType $sessionId ' + '(${missingIndices.length} needed fragment(s), ${peers.length} peer(s))', + ); + for (final peer in peers) { + await connectionProvider.sendRawVoicePacket( + contactPath: peer.outPath, + contactPathLen: peer.routeEncodedPathLen, + payload: request.encodeBinary(), + ); + } + + await Future.delayed(_mediaSwarmResponseWindow); + final orderedResponses = + responses.values + .where( + (response) => response.responderKey6 != originalSenderKey6, + ) + .toList() + ..sort((a, b) { + final aScore = _swarmResponseScore(a, missingIndices); + final bScore = _swarmResponseScore(b, missingIndices); + return bScore.compareTo(aScore); + }); + + for (final response in orderedResponses) { + final responder = _resolveContactByPrefixHex(response.responderKey6); + if (responder == null || + !responder.routeHasPath || + responder.routeHopCount > _maxDirectPayloadHops || + responder.outPath.isEmpty) { + continue; + } + + final requestedSubset = response.servesAll + ? missingIndices + : missingIndices + .where(response.availableIndices.toSet().contains) + .toList(); + if (requestedSubset.isEmpty) { + continue; + } + + final requestedSet = requestedSubset.toSet(); + final sent = await _sendDirectMediaFetchRequest( + mediaType: mediaType, + sessionId: sessionId, + target: responder, + requesterKey6: requesterKey6, + missingIndices: requestedSet, + ); + if (sent) { + debugPrint( + '🌐 [AppProvider] Requested $mediaType $sessionId ' + 'from swarm peer ${responder.advName} ' + '(${requestedSubset.length} fragment(s))', + ); + return true; + } + } + + return false; + } catch (e) { + debugPrint( + '⚠️ [AppProvider] Media swarm request failed for $mediaType ' + '$sessionId: $e', + ); + return false; + } finally { + _pendingMediaSwarmResponses.remove(key); + } + }(); + + _pendingMediaSwarmFetches[key] = future; + try { + return await future; + } finally { + _pendingMediaSwarmFetches.remove(key); + } + } + + int _swarmResponseScore( + MediaSwarmAvailability response, + List missingIndices, + ) { + if (response.servesAll) { + return missingIndices.length; + } + final needed = missingIndices.toSet(); + return response.availableIndices.where(needed.contains).length; + } + + Future _sendDirectMediaFetchRequest({ + required String mediaType, + required String sessionId, + required Contact target, + required String requesterKey6, + required Set missingIndices, + }) async { + try { + final payload = switch (mediaType) { + 'voice' => VoiceFetchRequest( + sessionId: sessionId, + want: missingIndices.isEmpty ? 'all' : 'missing', + missingIndices: missingIndices.toList()..sort(), + requesterKey6: requesterKey6, + ).encodeBinary(), + 'image' => ImageFetchRequest( + sessionId: sessionId, + want: missingIndices.isEmpty ? 'all' : 'missing', + missingIndices: missingIndices.toList()..sort(), + requesterKey6: requesterKey6, + ).encodeBinary(), + _ => null, + }; + if (payload == null) { + return false; + } + + await connectionProvider.sendRawVoicePacket( + contactPath: target.outPath, + contactPathLen: target.routeEncodedPathLen, + payload: payload, + ); + return true; + } catch (e) { + debugPrint( + '⚠️ [AppProvider] Direct $mediaType fetch via ${target.advName} failed: $e', + ); + return false; + } + } + + void _scheduleVoiceMissingRetry( + String sessionId, { + required bool justComplete, + }) { + if (voiceProvider.isReceiveCanceled(sessionId)) { + _clearVoiceMissingRetry(sessionId); + return; + } + if (justComplete || voiceProvider.isComplete(sessionId)) { + _clearVoiceMissingRetry(sessionId); + return; + } + + _voiceMissingRetryAttempts[sessionId] = 0; + _voiceMissingRetryTimers[sessionId]?.cancel(); + _voiceMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingVoicePackets(sessionId)); + }); + } + + void _scheduleImageMissingRetry( + String sessionId, { + required bool justComplete, + }) { + if (imageProvider.isReceiveCanceled(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + if (justComplete || imageProvider.isComplete(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + + _imageMissingRetryAttempts[sessionId] = 0; + _imageMissingRetryTimers[sessionId]?.cancel(); + _imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingImageFragments(sessionId)); + }); + } + + void _clearVoiceMissingRetry(String sessionId) { + _voiceMissingRetryTimers.remove(sessionId)?.cancel(); + _voiceMissingRetryAttempts.remove(sessionId); + } + + void _clearImageMissingRetry(String sessionId) { + _imageMissingRetryTimers.remove(sessionId)?.cancel(); + _imageMissingRetryAttempts.remove(sessionId); + } + + Future _requestMissingVoicePackets(String sessionId) async { + if (voiceProvider.isReceiveCanceled(sessionId)) { + _clearVoiceMissingRetry(sessionId); + return; + } + if (voiceProvider.isComplete(sessionId)) { + _clearVoiceMissingRetry(sessionId); + return; + } + + final attempt = _voiceMissingRetryAttempts[sessionId] ?? 0; + if (attempt >= _maxPacketRetryAttempts) { + debugPrint( + '⚠️ [AppProvider] Voice re-request limit reached for $sessionId', + ); + _clearVoiceMissingRetry(sessionId); + return; + } + + final senderKey6 = _voiceSessionSenderKey6[sessionId]; + if (senderKey6 == null) return; + final sender = _resolveContactByPrefixHex(senderKey6); + final requesterKey6 = _deviceKey6Hex(); + if (requesterKey6 == null) return; + + final missing = voiceProvider.missingPacketIndices(sessionId); + if (missing.isEmpty) { + _clearVoiceMissingRetry(sessionId); + return; + } + + var sent = false; + if (sender != null) { + final routeOk = await verifyRawTransportRoute(sender); + if (routeOk) { + sent = await _sendDirectMediaFetchRequest( + mediaType: 'voice', + sessionId: sessionId, + target: sender, + requesterKey6: requesterKey6, + missingIndices: missing.toSet(), + ); + } + } + if (!sent) { + sent = await _requestMissingMediaViaSwarm( + mediaType: 'voice', + sessionId: sessionId, + missingIndices: missing, + originalSenderKey6: senderKey6, + ); + } + if (!sent) { + return; + } + + _voiceMissingRetryAttempts[sessionId] = attempt + 1; + _voiceMissingRetryTimers[sessionId]?.cancel(); + _voiceMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingVoicePackets(sessionId)); + }); + } + + Future _requestMissingImageFragments(String sessionId) async { + if (imageProvider.isReceiveCanceled(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + if (imageProvider.isComplete(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + + final attempt = _imageMissingRetryAttempts[sessionId] ?? 0; + if (attempt >= _maxPacketRetryAttempts) { + debugPrint( + '⚠️ [AppProvider] Image re-request limit reached for $sessionId', + ); + _clearImageMissingRetry(sessionId); + return; + } + + final senderKey6 = _imageSessionSenderKey6[sessionId]; + if (senderKey6 == null) return; + final sender = _resolveContactByPrefixHex(senderKey6); + final requesterKey6 = _deviceKey6Hex(); + if (requesterKey6 == null) return; + + final missing = imageProvider.missingFragmentIndices(sessionId); + if (missing.isEmpty) { + _clearImageMissingRetry(sessionId); + return; + } + + var sent = false; + if (sender != null) { + final routeOk = await verifyRawTransportRoute(sender); + if (routeOk) { + sent = await _sendDirectMediaFetchRequest( + mediaType: 'image', + sessionId: sessionId, + target: sender, + requesterKey6: requesterKey6, + missingIndices: missing.toSet(), + ); + } + } + if (!sent) { + sent = await _requestMissingMediaViaSwarm( + mediaType: 'image', + sessionId: sessionId, + missingIndices: missing, + originalSenderKey6: senderKey6, + ); + } + if (!sent) { + return; + } + + _imageMissingRetryAttempts[sessionId] = attempt + 1; + _imageMissingRetryTimers[sessionId]?.cancel(); + _imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingImageFragments(sessionId)); + }); + } + + bool _handleFastGpsPayload(Uint8List payload) { + final fastGpsPacket = FastGpsPacket.tryParseBinary(payload); + if (fastGpsPacket == null) { + return false; + } + + // Heard a peer beacon — yield the channel briefly before our own send, to + // avoid collisions (mirrors the firmware's post-RX hold-off). + locationTrackingService.noteFastGpsBeaconHeard(); + + final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6); + if (sender != null) { + contactsProvider.updateFastGps( + sender.publicKey.sublist(0, 6), + fastGpsPacket, + ); + } + return true; + } + + bool _handleIncomingImageBinaryPayload( + Uint8List payload, { + bool allowPreEnvelopeBuffer = false, + }) { + if (!ImagePacket.isImageBinary(payload)) { + return false; + } + final frag = ImagePacket.tryParseBinary(payload); + if (frag == null) { + return true; + } + debugPrint('📷 [AppProvider] Binary image fragment received: $frag'); + final session = imageProvider.session(frag.sessionId); + if (session == null && frag.total < 1) { + if (allowPreEnvelopeBuffer) { + _pendingChannelImageFragments.putIfAbsent( + frag.sessionId, + () => {}, + )[frag.index] = frag; + debugPrint( + '📷 [AppProvider] Buffered compact image fragment before envelope ' + 'for session ${frag.sessionId} index=${frag.index}', + ); + } else { + debugPrint( + '⚠️ [AppProvider] Dropping compact image fragment without envelope ' + 'for session ${frag.sessionId}', + ); + } + return true; + } + + imageProvider.addFragment( + session == null + ? frag + : ImagePacket( + sessionId: frag.sessionId, + format: session.format, + index: frag.index, + total: session.total, + data: frag.data, + ), + width: session?.width ?? 0, + height: session?.height ?? 0, + ); + _scheduleImageMissingRetry( + frag.sessionId, + justComplete: imageProvider.isComplete(frag.sessionId), + ); + return true; + } + + bool _handleIncomingVoiceBinaryPayload( + Uint8List payload, { + bool allowPreEnvelopeBuffer = false, + }) { + if (!VoicePacket.isVoiceBinary(payload)) { + return false; + } + final pkt = VoicePacket.tryParseBinary(payload); + if (pkt == null) { + return true; + } + debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt'); + final session = voiceProvider.session(pkt.sessionId); + if (session == null && pkt.total < 1) { + if (allowPreEnvelopeBuffer) { + _pendingChannelVoicePackets.putIfAbsent( + pkt.sessionId, + () => {}, + )[pkt.index] = pkt; + debugPrint( + '🎙️ [AppProvider] Buffered compact voice packet before envelope ' + 'for session ${pkt.sessionId} index=${pkt.index}', + ); + } else { + debugPrint( + '⚠️ [AppProvider] Dropping compact voice packet without envelope ' + 'for session ${pkt.sessionId}', + ); + } + return true; + } + + final normalizedPacket = session == null + ? pkt + : VoicePacket( + sessionId: pkt.sessionId, + mode: session.mode, + index: pkt.index, + total: session.total, + codec2Data: pkt.codec2Data, + ); + final justComplete = voiceProvider.addPacket(normalizedPacket); + _scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete); + _handleIncomingVoicePacket(normalizedPacket, justComplete: justComplete); + return true; + } + + void _replayPendingChannelVoicePackets(String sessionId) { + final pending = _pendingChannelVoicePackets.remove(sessionId); + final session = voiceProvider.session(sessionId); + if (pending == null || pending.isEmpty || session == null) { + return; + } + + final indices = pending.keys.toList()..sort(); + for (final index in indices) { + final packet = pending[index]!; + final normalizedPacket = VoicePacket( + sessionId: packet.sessionId, + mode: session.mode, + index: packet.index, + total: session.total, + codec2Data: packet.codec2Data, + ); + final justComplete = voiceProvider.addPacket(normalizedPacket); + _scheduleVoiceMissingRetry(sessionId, justComplete: justComplete); + _handleIncomingVoicePacket(normalizedPacket, justComplete: justComplete); + } + } + + void _replayPendingChannelImageFragments(String sessionId) { + final pending = _pendingChannelImageFragments.remove(sessionId); + final session = imageProvider.session(sessionId); + if (pending == null || pending.isEmpty || session == null) { + return; + } + + final indices = pending.keys.toList()..sort(); + for (final index in indices) { + final fragment = pending[index]!; + final normalizedFragment = ImagePacket( + sessionId: fragment.sessionId, + format: session.format, + index: fragment.index, + total: session.total, + data: fragment.data, + ); + imageProvider.addFragment( + normalizedFragment, + width: session.width, + height: session.height, + ); + _scheduleImageMissingRetry( + sessionId, + justComplete: imageProvider.isComplete(sessionId), + ); + } + } + + /// Insert or update a voice placeholder message for binary raw-data packets. + /// + /// Binary voice packets arrive without a chat message, so we synthesise one + /// to give the user a playable bubble in the message list. + void _handleIncomingVoicePacket( + VoicePacket pkt, { + required bool justComplete, + }) { + final sessionId = pkt.sessionId; + + // Check if a placeholder for this session already exists + final existing = messagesProvider.messages + .where((m) => m.isVoice && m.voiceId == sessionId) + .firstOrNull; + + if (existing != null) { + // Already have a placeholder — no need to add another + return; + } + + // First packet of a new session: insert placeholder message + final msgId = 'voice_$sessionId'; + final placeholder = Message( + id: msgId, + messageType: MessageType.contact, // direct contact (binary only) + senderPublicKeyPrefix: null, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + // Persist the first real packet in legacy V: text form so UI/debug paths + // can reconstruct packet metadata from actual data. + text: pkt.encodeText(), + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.received, + isVoice: true, + voiceId: sessionId, + ); + messagesProvider.addMessage(placeholder, contactLookup: (_) => ''); + } + + String _rawProbeKey(int nonce) => + nonce.toRadixString(16).padLeft(8, '0').toLowerCase(); + + Future verifyRawTransportRoute( + Contact target, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (!connectionProvider.deviceInfo.isConnected) { + return false; + } + if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) { + return false; + } + if (target.outPath.isEmpty) { + return false; + } + + final probeKey = _routeProbeTargetKey(target); + final pendingProbe = _pendingRawRouteProbes[probeKey]; + if (pendingProbe != null) { + return pendingProbe; + } + + final deviceKey = connectionProvider.deviceInfo.publicKey; + if (deviceKey == null || deviceKey.length < 6) { + return false; + } + + final requesterKey6 = deviceKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final future = () async { + final nonce = math.Random.secure().nextInt(0x100000000); + final ackFuture = _rawProbeWaiters.waitFor( + _rawProbeKey(nonce), + timeout: timeout, + ); + + try { + debugPrint( + '📡 [AppProvider] Outgoing raw route probe: target=${target.advName} hops=${target.routeHopCount} nonce=${nonce.toRadixString(16)}', + ); + await connectionProvider.sendRawVoicePacket( + contactPath: target.outPath, + contactPathLen: target.routeEncodedPathLen, + payload: RawRouteProbeRequest( + nonce: nonce, + requesterKey6: requesterKey6, + ).encodeBinary(), + ); + return await ackFuture; + } catch (e) { + debugPrint( + '⚠️ [AppProvider] Raw route probe failed for ${target.advName}: $e', + ); + _rawProbeWaiters.complete(_rawProbeKey(nonce)); + return false; + } + }(); + + _pendingRawRouteProbes[probeKey] = future; + try { + return await future; + } finally { + _pendingRawRouteProbes.remove(probeKey); + } + } + + String _routeProbeTargetKey(Contact target) { + if (target.publicKeyHex.isNotEmpty) { + return 'pk:${target.publicKeyHex}'; + } + return 'name:${target.advName}:${target.routeEncodedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; + } + + void _handleRawRouteProbeRequest(RawRouteProbeRequest request) { + final requester = _resolveContactByPrefixHex(request.requesterKey6); + if (requester == null) { + debugPrint( + '⚠️ [AppProvider] Raw route probe requester not found: ${request.requesterKey6}', + ); + return; + } + if (!requester.routeHasPath || + requester.routeHopCount > _maxDirectPayloadHops) { + debugPrint( + '⚠️ [AppProvider] Raw route probe requester out of range: ${requester.routeHopCount}', + ); + return; + } + if (requester.outPath.isEmpty) { + return; + } + debugPrint( + '📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.routeHopCount} nonce=${request.nonce.toRadixString(16)}', + ); + unawaited( + connectionProvider.sendRawVoicePacket( + contactPath: requester.outPath, + contactPathLen: requester.routeEncodedPathLen, + payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(), + ), + ); + } + + void _completeRawRouteProbeAck(int nonce) { + _rawProbeWaiters.complete(_rawProbeKey(nonce)); + } + + MessageReceptionDetails? _buildReceptionDetailsSnapshot(Message message) { + final matchedRxLog = _findBestMatchingRxLog(message); + final estimatedTx = estimateMessageTransmitDuration( + message, + radioBw: connectionProvider.deviceInfo.radioBw, + radioSf: connectionProvider.deviceInfo.radioSf, + radioCr: connectionProvider.deviceInfo.radioCr, + ); + final senderToReceiptMs = _senderToReceiptMs(message); + final estimatedTransmitMs = sanitizeEstimatedTransmitMs( + estimatedTransmitMs: estimatedTx > Duration.zero + ? estimatedTx.inMilliseconds + : null, + senderToReceiptMs: senderToReceiptMs, + ); + final postTransmitDelayMs = + senderToReceiptMs != null && estimatedTransmitMs != null + ? (senderToReceiptMs - estimatedTransmitMs).clamp(0, 86400000).toInt() + : null; + + if (matchedRxLog == null && + senderToReceiptMs == null && + estimatedTransmitMs == null) { + return null; + } + + return MessageReceptionDetails( + capturedAt: DateTime.now(), + packetLoggedAt: matchedRxLog?.timestamp, + rssiDbm: matchedRxLog?.logRxDataInfo?.rssiDbm, + snrDb: matchedRxLog?.logRxDataInfo?.snrDb, + pathBytes: _extractPathBytesFromLog(matchedRxLog), + senderToReceiptMs: senderToReceiptMs, + estimatedTransmitMs: estimatedTransmitMs, + postTransmitDelayMs: postTransmitDelayMs, + ); + } + + int? _senderToReceiptMs(Message message) { + if (message.senderTimestamp <= 0) return null; + final senderAt = DateTime.fromMillisecondsSinceEpoch( + message.senderTimestamp * 1000, + isUtc: true, + ); + final deltaMs = message.receivedAt + .toUtc() + .difference(senderAt) + .inMilliseconds; + if (deltaMs < 0 || deltaMs > 86400000) return null; + return deltaMs; + } + + BlePacketLog? _findBestMatchingRxLog(Message message) { + if (message.pathLen < 0 || message.pathLen >= 255) return null; + final expectedPayloadType = message.messageType == MessageType.channel + ? 0x05 + : 0x02; + BlePacketLog? bestLog; + var bestDeltaMs = 999999999; + + for (final log in connectionProvider.bleService.packetLogs) { + if (log.responseCode != 0x88) continue; + if (log.rawData.length < 6) continue; + + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null) continue; + if (decoded.payloadType != expectedPayloadType) continue; + if (decoded.hopCount != message.pathLen) continue; + + final deltaMs = + (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); + if (deltaMs < bestDeltaMs) { + bestDeltaMs = deltaMs; + bestLog = log; + } + } + + if (bestDeltaMs > 30000) return null; + return bestLog; + } + + DecodedLogRxRoute? _findBestMatchingAdvertRxRoute(Uint8List publicKey) { + final publicKeyHex = publicKey + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + + DecodedLogRxRoute? bestRoute; + var bestDeltaMs = 999999999; + final now = DateTime.now(); + + for (final log in connectionProvider.bleService.packetLogs) { + if (log.responseCode != 0x88) continue; + if (log.rawData.length < 6) continue; + + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null) continue; + if (decoded.payloadType != 0x04) continue; + final senderHash = decoded.originalSenderHashHex; + if (senderHash == null || !publicKeyHex.startsWith(senderHash)) continue; + + final deltaMs = (log.timestamp.difference(now).inMilliseconds).abs(); + if (deltaMs < bestDeltaMs) { + bestDeltaMs = deltaMs; + bestRoute = decoded; + } + } + + if (bestDeltaMs > 30000) return null; + return bestRoute; + } + + List? _extractPathBytesFromLog(BlePacketLog? log) { + if (log == null) return null; + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null || decoded.pathBytes.isEmpty) return null; + return decoded.pathBytes; + } + + Uint8List? _latestChannelEchoPathBytes() { + for (final log in connectionProvider.bleService.packetLogs.reversed) { + if (log.responseCode != 0x88) continue; + + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null || + decoded.payloadType != 0x05 || + decoded.pathBytes.isEmpty) { + continue; + } + + return Uint8List.fromList(decoded.pathBytes); + } + + return null; + } + + // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events + // The ConnectionProvider's onMessageWaiting callback handles automatic message fetching + + /// Refresh data (contacts and channels - messages are handled via events) + Future refresh() async { + if (!connectionProvider.deviceInfo.isConnected) return; + + try { + // Sync contacts. The device list is authoritative: contacts it no + // longer has are deleted locally once a complete list was received. + await contactsProvider.prepareForDeviceContactSync( + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + final contactListComplete = await connectionProvider.getContacts(); + contactsProvider.finalizeDeviceContactSync( + deviceListComplete: contactListComplete, + ); + + // Sync all channels so refresh reflects the full device state. + channelsProvider.prepareForDeviceSync(); + await connectionProvider.syncChannels( + maxChannels: connectionProvider.deviceInfo.maxChannels, + ); + + // Messages are automatically synced via PUSH_CODE_MSG_WAITING events + notifyListeners(); + } catch (e) { + debugPrint('Refresh error: $e'); + } + } + + /// Manually sync messages (only for explicit user pull-to-refresh) + /// Note: Messages are automatically synced via PUSH_CODE_MSG_WAITING events + /// This method should ONLY be called when the user explicitly pulls to refresh + Future syncMessages() async { + if (!connectionProvider.deviceInfo.isConnected) return 0; + + try { + debugPrint( + '🔄 [AppProvider] Manual message sync requested (user initiated)', + ); + final messageCount = await connectionProvider.syncAllMessages( + force: true, + ); + debugPrint( + '✅ [AppProvider] Manual sync completed: $messageCount messages', + ); + notifyListeners(); + return messageCount; + } catch (e) { + debugPrint('❌ [AppProvider] Message sync error: $e'); + return 0; + } + } + + /// Handle connection state changes to manage location tracking + void _handleConnectionStateChange() { + final isConnected = connectionProvider.deviceInfo.isConnected; + final wasConnected = _wasDeviceConnected; + _wasDeviceConnected = isConnected; + final wasTracking = locationTrackingService.isTracking; + + if (isConnected != wasConnected) { + unawaited(_clearAppFallbackChannelLocationSharingState()); + } + + // Only stop tracking on disconnect - DON'T start on connect + // Location tracking will be started AFTER initialization completes + if (!isConnected && wasTracking) { + // Connection lost - stop location tracking + debugPrint( + '🔴 [AppProvider] BLE disconnected - stopping location tracking', + ); + _stopLocationTracking(); + } + + if (!isConnected) { + connectionProvider.clearPendingAutomaticMessageSync(); + } + + if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) { + _isReconnectSyncInProgress = true; + unawaited(_syncAfterReconnect(started: true)); + } + } + + bool _canStartAutomaticMessageSync() { + return connectionProvider.deviceInfo.isConnected && + _hasCompletedConnectionBootstrap && + !_isReconnectSyncInProgress; + } + + Future _flushDeferredAutomaticMessageSync() async { + if (!connectionProvider.hasPendingAutomaticMessageSync || + !_canStartAutomaticMessageSync()) { + return; + } + + debugPrint( + '🔄 [AppProvider] Running deferred automatic message sync after bootstrap', + ); + await connectionProvider.syncAllMessages(force: true); + } + + /// Start location tracking + Future _startLocationTracking() async { + try { + final started = await locationTrackingService.startTracking(); + if (started) { + debugPrint('✅ [AppProvider] Location tracking started successfully'); + } else { + debugPrint('⚠️ [AppProvider] Failed to start location tracking'); + } + } catch (e) { + debugPrint('❌ [AppProvider] Error starting location tracking: $e'); + } + } + + /// Stop location tracking + Future _stopLocationTracking() async { + try { + await locationTrackingService.stopTracking(); + debugPrint('✅ [AppProvider] Location tracking stopped'); + } catch (e) { + debugPrint('❌ [AppProvider] Error stopping location tracking: $e'); + } + } + + /// Clear all data + void clearAllData() { + contactsProvider.clearContacts(); + messagesProvider.clearAll(); + unawaited(voiceProvider.clearStoredVoiceData()); + unawaited(imageProvider.clearAll()); + for (final timer in _voiceMissingRetryTimers.values) { + timer.cancel(); + } + for (final timer in _imageMissingRetryTimers.values) { + timer.cancel(); + } + _voiceMissingRetryTimers.clear(); + _voiceMissingRetryAttempts.clear(); + _imageMissingRetryTimers.clear(); + _imageMissingRetryAttempts.clear(); + _voiceSessionSenderKey6.clear(); + _imageSessionSenderKey6.clear(); + _pendingChannelVoicePackets.clear(); + _pendingChannelImageFragments.clear(); + _lowBatteryNotifiedNodeIds.clear(); + notifyListeners(); + } + + void _handleControlDataDiscovery({ + required Uint8List payload, + required int snrRaw, + required int rssiDbm, + required int pathLen, + }) { + const int controlTypeMask = 0xF0; + const int controlTypeNodeDiscoverResp = 0x90; + const int minFullDiscoverResponseLength = 6 + 32; + + if (payload.length < minFullDiscoverResponseLength) { + return; + } + + final controlType = payload[0] & controlTypeMask; + if (controlType != controlTypeNodeDiscoverResp) { + return; + } + + final nodeType = payload[0] & 0x0F; + final publicKey = Uint8List.fromList(payload.sublist(6, 38)); + final isNewPendingAdvert = contactsProvider + .addOrUpdatePendingAdvertMetadata( + publicKey: publicKey, + typeValue: nodeType, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + rxRssiDbm: rssiDbm, + rxSnrRaw: snrRaw, + signedEncodedPathLen: pathLen == 0 ? 0 : null, + paddedPathBytes: pathLen == 0 + ? Uint8List(ContactRouteCodec.maxPathBytes) + : null, + ); + + debugPrint( + '🛰️ [AppProvider] Control discovery response: type=$nodeType ' + 'pathLen=$pathLen snr=$snrRaw rssi=$rssiDbm new=$isNewPendingAdvert', + ); + + // Official app flow for controlData discovery: + // - Try getContact from firmware. If not found → skip. + // - If found → update path, request region names for repeaters. + // - Discovery list shows generic labels ("Repeater XX:YY") for unknown nodes. + // - Names appear later when the node's advert arrives via pushAdvert/pushNewAdvert. + + if (isNewPendingAdvert) { + final keyHex = publicKey + .take(6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + unawaited( + _notificationService.showContactDiscoveredNotification( + contactKey: keyHex, + contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName, + ), + ); + } + + if (nodeType == ContactType.repeater.value) { + unawaited(_requestRepeaterStatus(publicKey)); + unawaited(_maybeRequestRepeaterOwnerInfo(publicKey)); + } + + // Try to enrich from firmware's existing contacts (no import — the + // controlData discovery response doesn't store an advert in firmware). + if (contactsProvider.shouldEnrichPendingAdvert(publicKey)) { + unawaited(connectionProvider.previewContact(publicKey)); + } + } + + /// Get app statistics + Map get statistics { + return { + 'connection': { + 'isConnected': connectionProvider.deviceInfo.isConnected, + 'deviceName': connectionProvider.deviceInfo.deviceName, + 'battery': connectionProvider.deviceInfo.batteryPercent, + }, + 'contacts': contactsProvider.contactCounts, + 'messages': messagesProvider.messageStats, + 'sarMarkers': messagesProvider.sarMarkerStats, + }; + } + + @override + void dispose() { + _packetCaptureFlushTimer?.cancel(); + _lowBatteryCheckTimer?.cancel(); + unawaited(_flushPacketCaptureLogs()); + // Remove connection state listener + connectionProvider.removeListener(_handleConnectionStateChange); + // Clear location service callbacks + locationTrackingService.onPositionUpdate = null; + locationTrackingService.onBroadcastSent = null; + locationTrackingService.onError = null; + locationTrackingService.onTrackingStateChanged = null; + locationTrackingService.onFastLocationUpdate = null; + // Dispose the location tracking service to stop GPS stream and clean up resources + locationTrackingService.dispose(); + for (final timer in _voiceMissingRetryTimers.values) { + timer.cancel(); + } + for (final timer in _imageMissingRetryTimers.values) { + timer.cancel(); + } + _pendingChannelVoicePackets.clear(); + _pendingChannelImageFragments.clear(); + trafficStatsReportingService.dispose(); + super.dispose(); + } +} diff --git a/lib/providers/channels_provider.dart b/lib/providers/channels_provider.dart new file mode 100644 index 0000000..da8957e --- /dev/null +++ b/lib/providers/channels_provider.dart @@ -0,0 +1,128 @@ +import 'package:flutter/foundation.dart'; +import '../models/channel.dart'; + +/// Manages channel information from the MeshCore device +class ChannelsProvider with ChangeNotifier { + final Map _channels = {}; + int _selectedChannelIndex = 0; // Default to public channel + + /// Get all channels + List 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]; + + /// Get the currently selected channel + Channel? get selectedChannel => _channels[_selectedChannelIndex]; + + /// Get the selected channel index + int get selectedChannelIndex => _selectedChannelIndex; + + /// Get the display name for a channel + String getChannelDisplayName(int index) { + final channel = _channels[index]; + if (channel != null) { + return channel.displayName; + } + // Fallback if channel hasn't been synced yet + return index == 0 ? 'Public' : 'Channel $index'; + } + + List 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, + required String name, + required Uint8List secret, + int? flags, + }) { + _channels[index] = Channel( + index: index, + name: name, + secret: secret, + flags: flags, + ); + notifyListeners(); + } + + /// Add or update a channel using Channel object + void addOrUpdateChannelObject(Channel channel) { + _channels[channel.index] = channel; + notifyListeners(); + } + + /// Remove a channel by index + 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(); + } + } + + /// Select a channel for sending messages + void selectChannel(int index) { + if (_channels.containsKey(index) || index == 0) { + _selectedChannelIndex = index; + notifyListeners(); + } + } + + /// Get channels by type (hash-based vs normal) + List getHashChannels() { + return channels.where((c) => c.isHashChannel).toList(); + } + + List getNormalChannels() { + return channels.where((c) => !c.isHashChannel).toList(); + } + + /// Initialize default public channel + void initializePublicChannel() { + if (!_channels.containsKey(0)) { + _channels[0] = Channel.publicChannel(); + notifyListeners(); + } + } + + /// Clear all channels + void clear() { + _channels.clear(); + _selectedChannelIndex = 0; + 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; + + /// Get the number of channels + int get channelCount => _channels.length; + + @override + void dispose() { + _channels.clear(); + super.dispose(); + } +} diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart new file mode 100644 index 0000000..a8458fa --- /dev/null +++ b/lib/providers/connection_provider.dart @@ -0,0 +1,3068 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:crypto/crypto.dart'; +import '../models/contact.dart'; +import '../models/device_info.dart'; +import '../models/room_login_state.dart'; +import 'package:meshcore_client/meshcore_client.dart' hide Contact; +import '../utils/sar_message_parser.dart'; +import 'helpers/room_login_manager.dart'; +import 'helpers/message_delivery_tracker.dart'; +import 'helpers/ping_tracker.dart'; + +/// Pending send operation for auto-recovery +class _PendingSendOperation { + final Uint8List contactPublicKey; + final String text; + final String? messageId; + final Contact? contact; + final int retryAttempt; + + _PendingSendOperation({ + required this.contactPublicKey, + required this.text, + this.messageId, + this.contact, + this.retryAttempt = 0, + }); +} + +/// Result of a ping (telemetry request) operation +class PingResult { + final bool success; + final bool usedFlooding; + final bool timedOut; + final bool retriedWithFlooding; + + const PingResult({ + required this.success, + required this.usedFlooding, + required this.timedOut, + this.retriedWithFlooding = false, + }); +} + +/// 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; + final int rssi; + + ScannedDevice({required this.device, required this.rssi}); +} + +enum ContactReceiveSource { sync, requestedSingle, preview, advert } + +class _PendingContactRequest { + final ContactReceiveSource source; + final DateTime requestedAt; + + const _PendingContactRequest({ + required this.source, + required this.requestedAt, + }); +} + +/// Connection Provider - manages MeshCore device connection (BLE or TCP/WiFi) +class ConnectionProvider with ChangeNotifier { + static const int _controlTypeNodeDiscoverReq = 0x80; + final MeshCoreBleService _bleService = MeshCoreBleService(); + MeshCoreTcpService? _tcpService; + MeshCoreSerialService? _serialService; + Future Function()? _serialDisconnectTransport; + + /// Expose BLE service for background location tracking + MeshCoreBleService get bleService => _bleService; + + /// Active service — BLE, TCP, or USB depending on current mode + MeshCoreServiceBase get _activeService { + if (_connectionMode == ConnectionMode.tcp && _tcpService != null) { + return _tcpService!; + } + if (_connectionMode == ConnectionMode.usb && _serialService != null) { + return _serialService!; + } + return _bleService; + } + + /// Current connection mode + ConnectionMode _connectionMode = ConnectionMode.ble; + ConnectionMode get connectionMode => _connectionMode; + + /// TCP host last connected to (for display / reconnection info) + String? _tcpHost; + String? get tcpHost => _tcpHost; + + DeviceInfo _deviceInfo = DeviceInfo(); + DeviceInfo get deviceInfo => _deviceInfo; + + final List _scannedDevices = []; + List get scannedDevices => _scannedDevices; + + bool _isScanning = false; + bool get isScanning => _isScanning; + final Set _pendingDeletedChannelIndices = {}; + + String? _error; + String? get error => _error; + + // Activity indicators (for blinking) + bool _rxActivity = false; + bool _txActivity = false; + bool get rxActivity => _rxActivity; + bool get txActivity => _txActivity; + + Timer? _rxActivityTimer; + Timer? _txActivityTimer; + + // Periodic cleanup timer for stale ACK mappings + Timer? _ackCleanupTimer; + + // Packet counters + int get rxPacketCount => _activeService.rxPacketCount; + int get txPacketCount => _activeService.txPacketCount; + + // Reconnection state + bool get isReconnecting => _activeService.isReconnecting; + int get reconnectionAttempt => _activeService.reconnectionAttempt; + int get maxReconnectionAttempts => _activeService.maxReconnectionAttempts; + + // Message sync state + bool _noMoreMessages = false; + // Prevent overlapping/too-frequent sync requests + bool _isSyncingMessages = false; + // If MSG_WAITING arrives while a sync loop is active, queue one more pass. + bool _syncRequestedWhileBusy = false; + DateTime? _lastSyncNextRequestedAt; + static const Duration _minSyncNextInterval = Duration(milliseconds: 150); + + // Completer to wait for response before sending next sync request + Completer? _syncResponseCompleter; + + // Completer to wait for contacts sync to finish + Completer? _contactsSyncCompleter; + + // True only when the device signaled end-of-contacts for the last sync + // (false on timeout or disconnect, i.e. a possibly partial list). + bool _contactsSyncEndedNormally = false; + + // Lightweight guards for other commands that can be double-tapped + bool _isLoginInProgress = false; + DateTime? _lastLoginRequestedAt; + static const Duration _minLoginInterval = Duration(seconds: 1); + + bool _isStatusRequestInProgress = false; + DateTime? _lastStatusRequestedAt; + static const Duration _minStatusInterval = Duration(milliseconds: 200); + + bool _isAdvertInProgress = false; + DateTime? _lastAdvertRequestedAt; + static const Duration _minAdvertInterval = Duration(milliseconds: 500); + bool _isContactsSyncInProgress = false; + final Map _pendingSingleContactRequests = {}; + final Map _previewContactMisses = {}; + static const Duration _singleContactRequestWindow = Duration(seconds: 10); + static const Duration _previewContactMissTtl = Duration(minutes: 10); + bool _suppressNextPreviewNotFoundError = false; + bool? _supportsAutoaddConfig; + + // Helper instances + final RoomLoginManager _roomLoginManager = RoomLoginManager(); + final MessageDeliveryTracker _messageDeliveryTracker = + MessageDeliveryTracker(); + final PingTracker _pingTracker = PingTracker(); + final Map> _pendingSmartPings = {}; + final Map> _pendingRelayPings = {}; + final Map _relayPingStartTimes = {}; + + // Expose room login states + Map get roomLoginStates => + _roomLoginManager.roomLoginStates; + + bool isPingInProgress(Uint8List publicKey) => + _pendingSmartPings.containsKey(_publicKeyToHex(publicKey)); + + // Callbacks for other providers + Function(Contact)? onContactReceived; + Function(Contact, ContactReceiveSource)? onContactReceivedDetailed; + Function(List)? onContactsComplete; + Function(Message)? onMessageReceived; + Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived; + Function(int channelIdx, String channelName, Uint8List secret, int? flags)? + onChannelInfoReceived; + Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)? + onBinaryResponse; + Function(Uint8List publicKey)? onContactDeleted; + VoidCallback? onContactsFull; + Function(Uint8List publicKey)? onAdvertReceived; + Function(Uint8List publicKey)? onPathUpdated; + Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag)? + onLoginSuccess; + Function(Uint8List publicKeyPrefix)? onLoginFail; + Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)? + onMessageSent; + Function(int ackCode, int roundTripTimeMs)? onMessageDelivered; + Future Function({ + required String messageId, + required Contact contact, + required int retryAttempt, + })? + prepareDirectMessageSendCallback; + Function(String messageId, int echoCount, int snrRaw, int rssiDbm)? + 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; + bool Function()? canStartAutomaticMessageSyncCallback; + + // Track pending send operations for auto-recovery + final Map _pendingSendOperations = {}; + bool _pendingAutomaticMessageSync = false; + + ConnectionProvider() { + _wireServiceCallbacks(_bleService); + } + + Future _prepareForConnectionSwitch(ConnectionMode nextMode) async { + if (_isScanning) { + debugPrint('🔵 [Provider] Stopping active scan before connect()'); + await stopScan(); + } + + await _disconnectInactiveTransports(nextMode); + _error = null; + _supportsAutoaddConfig = null; + _resetSyncState(); + } + + Future _disconnectInactiveTransports(ConnectionMode activeMode) async { + if (activeMode != ConnectionMode.ble && _bleService.isConnected) { + await _bleService.disconnect(); + } + + if (activeMode != ConnectionMode.tcp && _tcpService != null) { + await _disposeTcpService(); + } + + if (activeMode != ConnectionMode.usb && _serialService != null) { + await _disposeSerialService(); + } + } + + Future _disposeTcpService() async { + final service = _tcpService; + if (service == null) { + return; + } + _tcpService = null; + await service.disconnect(); + service.dispose(); + } + + Future _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({ + required ConnectionMode mode, + required String deviceId, + required String deviceName, + String? tcpHost, + }) { + _connectionMode = mode; + _tcpHost = mode == ConnectionMode.tcp ? tcpHost : null; + _deviceInfo = _deviceInfo.copyWith( + deviceId: deviceId, + deviceName: deviceName, + connectionState: ConnectionState.connecting, + ); + notifyListeners(); + } + + void _resetConnectionSession({ConnectionMode nextMode = ConnectionMode.ble}) { + _tcpHost = nextMode == ConnectionMode.tcp ? _tcpHost : null; + _connectionMode = nextMode; + _supportsAutoaddConfig = null; + _resetSyncState(); + _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); + _roomLoginManager.clearRoomLoginStates(); + _pingTracker.clearAll(); + _pendingSendOperations.clear(); + _messageDeliveryTracker.clearTracking(); + notifyListeners(); + } + + /// Wire all shared event callbacks onto [service]. + /// Called for both BLE and TCP services so the provider handles events + /// identically regardless of transport. + void _wireServiceCallbacks(MeshCoreServiceBase service) { + service.onConnectionStateChanged = (isConnected) { + debugPrint('🔔 [Provider] Connection state callback fired: $isConnected'); + _deviceInfo = _deviceInfo.copyWith( + connectionState: isConnected + ? ConnectionState.connected + : (service.isReconnecting + ? ConnectionState.connecting + : ConnectionState.disconnected), + lastUpdate: DateTime.now(), + ); + debugPrint( + ' Updated deviceInfo.connectionState: ${_deviceInfo.connectionState}', + ); + debugPrint( + ' Updated deviceInfo.isConnected: ${_deviceInfo.isConnected}', + ); + debugPrint(' isReconnecting: ${service.isReconnecting}'); + + // Start/stop ACK cleanup timer based on connection state + if (isConnected) { + _startAckCleanupTimer(); + } else { + _stopAckCleanupTimer(); + } + + notifyListeners(); + debugPrint(' Notified listeners'); + }; + + service.onReconnectionAttempt = (attemptNumber, maxAttempts) { + debugPrint( + '🔄 [Provider] Reconnection attempt $attemptNumber/$maxAttempts', + ); + notifyListeners(); + }; + + service.onError = (error, {int? errorCode}) { + if (errorCode == 2 && _suppressNextPreviewNotFoundError) { + _suppressNextPreviewNotFoundError = false; + return; + } + debugPrint('⚠️ [Provider] Error received: $error'); + _error = error; + if (_deviceInfo.connectionState != ConnectionState.connected) { + _deviceInfo = _deviceInfo.copyWith( + connectionState: ConnectionState.error, + ); + } + notifyListeners(); + }; + + service.onContactNotFound = (contactPublicKey) async { + if (contactPublicKey == null) return; + + final keyHex = _publicKeyToHex(contactPublicKey); + final request = _pendingSingleContactRequests.remove(keyHex); + if (request?.source == ContactReceiveSource.preview) { + _previewContactMisses[keyHex] = DateTime.now(); + _suppressNextPreviewNotFoundError = true; + debugPrint( + '🔧 [Provider] Preview contact not found, suppressing retries for $keyHex', + ); + return; + } + + debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery'); + + final operationId = contactPublicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); + final pendingOp = _pendingSendOperations[operationId]; + if (pendingOp == null || pendingOp.contact == null) return; + + try { + await _activeService.addOrUpdateContact(pendingOp.contact!); + + if (pendingOp.messageId != null) { + _messageDeliveryTracker.trackPendingDirectMessage( + pendingOp.messageId!, + pendingOp.contactPublicKey, + ); + } + + await _activeService.sendTextMessage( + contactPublicKey: pendingOp.contactPublicKey, + text: pendingOp.text, + attempt: pendingOp.retryAttempt, + ); + _pendingSendOperations.remove(operationId); + } catch (e) { + debugPrint(' ❌ Auto-recovery failed: $e'); + _error = 'Auto-recovery failed: $e'; + notifyListeners(); + _pendingSendOperations.remove(operationId); + } + }; + + service.onContactReceived = (contact) { + debugPrint('📥 [Provider] Contact received: "${contact.advName}"'); + final source = _classifyContactReceiveSource(contact.publicKey); + onContactReceivedDetailed?.call(contact, source); + onContactReceived?.call(contact); + }; + + service.onContactsComplete = (contacts) { + debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}'); + _isContactsSyncInProgress = false; + _contactsSyncEndedNormally = true; + if (_contactsSyncCompleter != null && + !_contactsSyncCompleter!.isCompleted) { + _contactsSyncCompleter!.complete(); + } + onContactsComplete?.call(contacts); + }; + + service.onChannelInfoReceived = + (int channelIdx, String channelName, Uint8List secret, int? flags) { + onChannelInfoReceived?.call(channelIdx, channelName, secret, flags); + }; + + service.onContactDeleted = (publicKey) => onContactDeleted?.call(publicKey); + service.onContactsFull = () => onContactsFull?.call(); + + service.onMessageReceived = (message) { + final enhancedMessage = SarMessageParser.enhanceMessage(message); + onMessageReceived?.call(enhancedMessage); + if (_syncResponseCompleter != null && + !_syncResponseCompleter!.isCompleted) { + _syncResponseCompleter!.complete(true); + } + }; + + service.onTelemetryReceived = (publicKey, lppData) { + debugPrint('📥 [Provider] Telemetry received'); + _pingTracker.markPingSuccessful(publicKey); + onTelemetryReceived?.call(publicKey, lppData); + }; + + service.onBinaryResponse = (publicKeyPrefix, tag, responseData) { + debugPrint('📥 [Provider] Binary response received'); + _pingTracker.markPingSuccessful(publicKeyPrefix); + onBinaryResponse?.call(publicKeyPrefix, tag, responseData); + }; + + service.onNoMoreMessages = () { + _noMoreMessages = true; + if (_syncResponseCompleter != null && + !_syncResponseCompleter!.isCompleted) { + _syncResponseCompleter!.complete(false); + } + }; + + service.onMessageWaiting = () { + if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) { + _pendingAutomaticMessageSync = true; + debugPrint( + '📥 [Provider] MSG_WAITING deferred until connection bootstrap completes', + ); + return; + } + debugPrint('📥 [Provider] MSG_WAITING - auto-syncing'); + if (_isSyncingMessages) { + _syncRequestedWhileBusy = true; + debugPrint( + ' ↪️ [Provider] Sync already running; queued follow-up sync', + ); + return; + } + unawaited(syncAllMessages()); + }; + + service.onLoginSuccess = + (publicKeyPrefix, permissions, isAdmin, tag) async { + await _roomLoginManager.handleLoginSuccess( + publicKeyPrefix: publicKeyPrefix, + permissions: permissions, + isAdmin: isAdmin, + tag: tag, + ); + notifyListeners(); + onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag); + }; + + service.onLoginFail = (publicKeyPrefix) { + _roomLoginManager.handleLoginFail(publicKeyPrefix: publicKeyPrefix); + notifyListeners(); + onLoginFail?.call(publicKeyPrefix); + }; + + service.onAdvertReceived = (publicKey) => onAdvertReceived?.call(publicKey); + service.onPathUpdated = (publicKey) => onPathUpdated?.call(publicKey); + + service.onMessageSent = + (expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) { + final messageId = contactPublicKey != null + ? _messageDeliveryTracker.popPendingDirectMessageId( + contactPublicKey, + ) + : _messageDeliveryTracker.popPendingMessageId(); + if (messageId != null) { + _messageDeliveryTracker.mapAckTagToMessageId( + expectedAckTag, + messageId, + ); + onMessageSent?.call(messageId, expectedAckTag, suggestedTimeoutMs); + } + }; + + service.onMessageDelivered = (ackCode, roundTripTimeMs) => + onMessageDelivered?.call(ackCode, roundTripTimeMs); + + service.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) => + onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm); + + service.onStatusResponse = (publicKeyPrefix, statusData) => + onStatusResponse?.call(publicKeyPrefix, statusData); + + 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); + + service.onDeviceInfoReceived = (deviceInfo) { + debugPrint('📥 [Provider] DeviceInfo received'); + _deviceInfo = _deviceInfo.copyWith( + firmwareVersion: deviceInfo['firmwareVersion'] as int?, + maxContacts: deviceInfo['maxContacts'] as int?, + maxChannels: deviceInfo['maxChannels'] as int?, + blePin: deviceInfo['blePin'] as int?, + firmwareBuildDate: deviceInfo['firmwareBuildDate'] as String?, + manufacturerModel: deviceInfo['manufacturerModel'] as String?, + semanticVersion: deviceInfo['semanticVersion'] as String?, + clientRepeat: deviceInfo['clientRepeat'] as bool?, + 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(); + }; + + service.onSelfInfoReceived = (selfInfo) { + debugPrint('📥 [Provider] SelfInfo received'); + _deviceInfo = _deviceInfo.copyWith( + deviceType: selfInfo['deviceType'] as int?, + txPower: selfInfo['txPower'] as int?, + maxTxPower: selfInfo['maxTxPower'] as int?, + 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?, + radioSf: selfInfo['radioSf'] as int?, + radioCr: selfInfo['radioCr'] as int?, + selfName: selfInfo['selfName'] as String?, + ); + notifyListeners(); + }; + + service.onBatteryAndStorage = (millivolts, usedKb, totalKb) { + _deviceInfo = _deviceInfo.copyWith( + batteryMilliVolts: millivolts, + storageUsedKb: usedKb, + storageTotalKb: totalKb, + lastUpdate: DateTime.now(), + ); + notifyListeners(); + }; + + service.onRxActivity = () { + _rxActivity = true; + notifyListeners(); + _rxActivityTimer?.cancel(); + _rxActivityTimer = Timer(const Duration(milliseconds: 100), () { + _rxActivity = false; + notifyListeners(); + }); + }; + + service.onAllowedRepeatFreqReceived = (ranges) { + _deviceInfo = _deviceInfo.copyWith(allowedRepeatFreqRanges: ranges); + notifyListeners(); + }; + + service.onAutoaddConfigReceived = (config) { + _deviceInfo = _deviceInfo.copyWith( + autoAddUsers: config['autoAddUsers'] as bool?, + autoAddRepeaters: config['autoAddRepeaters'] as bool?, + 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(); + _txActivityTimer?.cancel(); + _txActivityTimer = Timer(const Duration(milliseconds: 100), () { + _txActivity = false; + notifyListeners(); + }); + }; + + service.onRssiUpdate = (rssi) { + _deviceInfo = _deviceInfo.copyWith( + signalRssi: rssi, + lastUpdate: DateTime.now(), + ); + notifyListeners(); + }; + } + + /// Start scanning for MeshCore devices + Future startScan() async { + debugPrint('🔍 [Provider] startScan() called'); + if (_deviceInfo.connectionState == ConnectionState.connecting || + _deviceInfo.connectionState == ConnectionState.connected) { + debugPrint( + '⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}', + ); + return; + } + + _isScanning = true; + _scannedDevices.clear(); + _error = null; + _notifyListenersSafely(); + 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), + )) { + debugPrint('📱 [Provider] Scan result received from scan stream'); + final device = scanResult.device; + final rssi = scanResult.rssi; + + if (!_scannedDevices.any((d) => d.device.remoteId == device.remoteId)) { + _scannedDevices.add(ScannedDevice(device: device, rssi: rssi)); + debugPrint( + '✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}', + ); + _notifyListenersSafely(); + } else { + // Update RSSI if device already exists + final index = _scannedDevices.indexWhere( + (d) => d.device.remoteId == device.remoteId, + ); + if (index != -1 && _scannedDevices[index].rssi != rssi) { + _scannedDevices[index] = ScannedDevice(device: device, rssi: rssi); + debugPrint( + ' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm', + ); + _notifyListenersSafely(); + } else { + debugPrint( + ' ⏭️ [Provider] Device already in list with same RSSI, skipping', + ); + } + } + } + } catch (e) { + debugPrint('❌ [Provider] Scan error: $e'); + _error = 'Scan error: $e'; + } finally { + debugPrint('🏁 [Provider] Scan completed'); + _isScanning = false; + _notifyListenersSafely(); + } + } + + Future _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 stopScan() async { + await FlutterBluePlus.stopScan(); + _isScanning = false; + _notifyListenersSafely(); + } + + void _notifyListenersSafely() { + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase == SchedulerPhase.transientCallbacks || + phase == SchedulerPhase.persistentCallbacks) { + SchedulerBinding.instance.addPostFrameCallback((_) { + notifyListeners(); + }); + return; + } + notifyListeners(); + } + + /// Connect to a device + Future connect(BluetoothDevice device) async { + debugPrint( + '🔵 [Provider] connect() called for device: ${device.platformName}', + ); + await _prepareForConnectionSwitch(ConnectionMode.ble); + _beginConnectionAttempt( + mode: ConnectionMode.ble, + deviceId: device.remoteId.toString(), + deviceName: device.platformName.isNotEmpty + ? device.platformName + : 'Unknown', + ); + debugPrint('✅ [Provider] Device info updated to connecting state'); + + debugPrint('🔵 [Provider] Calling BLE service connect()...'); + final success = await _bleService.connect(device); + + if (success) { + debugPrint('✅ [Provider] BLE service connect() returned success'); + } else { + debugPrint('❌ [Provider] BLE service connect() returned failure'); + _deviceInfo = _deviceInfo.copyWith( + connectionState: ConnectionState.error, + ); + notifyListeners(); + } + return success; + } + + /// Connect to a MeshCore device over TCP/WiFi (port 5000) + Future connectTcp(String host, int port) async { + debugPrint('🌐 [Provider] connectTcp() $host:$port'); + await _prepareForConnectionSwitch(ConnectionMode.tcp); + + // Create fresh TCP service and wire its callbacks + await _disposeTcpService(); + _tcpService = MeshCoreTcpService(); + _wireServiceCallbacks(_tcpService!); + _beginConnectionAttempt( + mode: ConnectionMode.tcp, + deviceId: '$host:$port', + deviceName: host, + tcpHost: host, + ); + + final success = await _tcpService!.connect(host, port); + if (!success) { + _deviceInfo = _deviceInfo.copyWith( + connectionState: ConnectionState.error, + ); + notifyListeners(); + } + return success; + } + + /// Disconnect from TCP/WiFi device + Future disconnectTcp() async { + await _disposeTcpService(); + _resetConnectionSession(); + } + + /// Connect via USB serial using a pre-configured [MeshCoreSerialService]. + /// + /// 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 connectSerial({ + required MeshCoreSerialService service, + required Future Function() disconnectTransport, + required String deviceId, + required String deviceName, + }) async { + debugPrint('🔌 [Provider] connectSerial()'); + await _prepareForConnectionSwitch(ConnectionMode.usb); + await _disposeSerialService(); + _serialService = service; + _serialDisconnectTransport = disconnectTransport; + _wireServiceCallbacks(_serialService!); + _beginConnectionAttempt( + mode: ConnectionMode.usb, + deviceId: deviceId, + deviceName: deviceName, + ); + + // markConnected() should already have been called by the transport. + // If it hasn't, the service won't be connected yet. + if (!service.isConnected) { + _deviceInfo = _deviceInfo.copyWith( + connectionState: ConnectionState.error, + ); + await _disposeSerialService(); + notifyListeners(); + return false; + } + return true; + } + + /// Disconnect from USB serial device. + Future disconnectSerial() async { + await _disposeSerialService(); + _resetConnectionSession(); + } + + /// Disconnect from device + Future disconnect() async { + _deviceInfo = _deviceInfo.copyWith( + connectionState: ConnectionState.disconnecting, + ); + notifyListeners(); + + if (_connectionMode == ConnectionMode.tcp) { + await disconnectTcp(); + return; + } + if (_connectionMode == ConnectionMode.usb) { + await disconnectSerial(); + return; + } + + await _bleService.disconnect(); + _resetConnectionSession(); + } + + /// Reset message sync state so the next connect/reconnect can sync cleanly. + void _resetSyncState() { + if (_syncResponseCompleter != null && + !_syncResponseCompleter!.isCompleted) { + _syncResponseCompleter!.complete(false); + } + _syncResponseCompleter = null; + if (_contactsSyncCompleter != null && + !_contactsSyncCompleter!.isCompleted) { + _contactsSyncCompleter!.complete(); + } + _contactsSyncCompleter = null; + _isSyncingMessages = false; + _syncRequestedWhileBusy = false; + _noMoreMessages = false; + _pendingAutomaticMessageSync = false; + } + + /// Cancel ongoing reconnection attempts + /// This is useful when the user wants to manually disconnect during reconnection + void cancelReconnection() { + debugPrint('🔴 [Provider] User requested cancellation of reconnection'); + disconnect(); + } + + /// Start periodic cleanup of stale ACK mappings + /// + /// Runs every minute to clean up ACK tags that haven't received + /// delivery confirmation within 5 minutes. + void _startAckCleanupTimer() { + _stopAckCleanupTimer(); // Cancel any existing timer first + + debugPrint( + '🧹 [ConnectionProvider] Starting ACK cleanup timer (1 minute interval)', + ); + _ackCleanupTimer = Timer.periodic(const Duration(minutes: 1), (_) { + final cleanedCount = _messageDeliveryTracker.cleanupStaleAcks(); + if (cleanedCount > 0) { + debugPrint( + '🧹 [ConnectionProvider] Cleaned up $cleanedCount stale ACK mappings', + ); + } + }); + } + + /// Stop periodic cleanup timer + void _stopAckCleanupTimer() { + _ackCleanupTimer?.cancel(); + _ackCleanupTimer = null; + } + + /// Get ACK tracking diagnostics + /// + /// Returns diagnostic information about pending ACKs for debugging. + /// Useful for troubleshooting message delivery issues. + Map getAckTrackingDiagnostics() { + return _messageDeliveryTracker.getDiagnostics(); + } + + /// Get contacts from device. + /// + /// Waits for the device to finish sending all contacts (up to 5 s timeout) + /// so callers don't need an arbitrary delay. + /// + /// Returns true only when the device signaled end-of-contacts, i.e. the + /// received list is complete and can be treated as authoritative. Returns + /// false on timeout, disconnect, or error (possibly partial list). + Future getContacts() async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return false; + } + + try { + _isContactsSyncInProgress = true; + _contactsSyncEndedNormally = false; + _contactsSyncCompleter = Completer(); + await _activeService.getContacts(); + await _contactsSyncCompleter!.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + debugPrint( + '⚠️ [Provider] Contacts sync timeout - proceeding without full list', + ); + }, + ); + return _contactsSyncEndedNormally; + } catch (e) { + _isContactsSyncInProgress = false; + _error = 'Failed to get contacts: $e'; + notifyListeners(); + return false; + } finally { + _isContactsSyncInProgress = false; + _contactsSyncCompleter = null; + } + } + + /// Get a single contact by public key from device + /// + /// This is more efficient than getContacts() when you only need to refresh + /// one specific contact (e.g., after receiving an advertisement or path update). + /// + /// The contact will be delivered via the onContactReceived callback. + Future getContact(Uint8List publicKey) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + _error = null; + _markSingleContactRequested( + publicKey, + source: ContactReceiveSource.requestedSingle, + ); + await _activeService.getContactByKey(publicKey); + } catch (e) { + final errorText = e.toString(); + if (_error == 'Not found' || errorText.contains('Not found')) { + _error = 'Not found'; + notifyListeners(); + return; + } + _error = 'Failed to get contact: $e'; + debugPrint( + '⚠️ [Provider] Failed to get contact by key, falling back to full contact sync', + ); + // Fallback to full contact sync if command not supported + _isContactsSyncInProgress = true; + await _activeService.getContacts(); + notifyListeners(); + } + } + + /// Import a received advert by public key, then fetch the full contact. + /// + /// This tells the firmware to store the advert it received, making the + /// contact available for [getContact]/[previewContact] with full details + /// (name, type, location, etc.). + Future importReceivedAdvert(Uint8List publicKey) async { + if (!_activeService.isConnected) return; + + try { + await _activeService.importReceivedAdvert(publicKey); + } catch (e) { + debugPrint( + '⚠️ [Provider] importReceivedAdvert failed for ${_publicKeyToHex(publicKey)}: $e', + ); + } + } + + Future previewContact(Uint8List publicKey) async { + if (!_activeService.isConnected) { + return; + } + _prunePreviewContactMisses(); + final keyHex = _publicKeyToHex(publicKey); + if (_previewContactMisses.containsKey(keyHex)) { + return; + } + + try { + _markSingleContactRequested( + publicKey, + source: ContactReceiveSource.preview, + ); + await _activeService.getContactByKey(publicKey); + } catch (e) { + debugPrint( + '⚠️ [Provider] Preview contact fetch failed for ${_publicKeyToHex(publicKey)}: $e', + ); + } + } + + ContactReceiveSource _classifyContactReceiveSource(Uint8List publicKey) { + _prunePendingSingleContactRequests(); + final keyHex = _publicKeyToHex(publicKey); + if (_isContactsSyncInProgress) { + return ContactReceiveSource.sync; + } + final request = _pendingSingleContactRequests.remove(keyHex); + if (request != null && + DateTime.now().difference(request.requestedAt) <= + _singleContactRequestWindow) { + return request.source; + } + return ContactReceiveSource.advert; + } + + void _markSingleContactRequested( + Uint8List publicKey, { + required ContactReceiveSource source, + }) { + _prunePendingSingleContactRequests(); + _pendingSingleContactRequests[_publicKeyToHex(publicKey)] = + _PendingContactRequest(source: source, requestedAt: DateTime.now()); + } + + void _prunePendingSingleContactRequests() { + if (_pendingSingleContactRequests.isEmpty) { + return; + } + + final now = DateTime.now(); + _pendingSingleContactRequests.removeWhere( + (_, request) => + now.difference(request.requestedAt) > _singleContactRequestWindow, + ); + } + + void _prunePreviewContactMisses() { + if (_previewContactMisses.isEmpty) { + return; + } + final now = DateTime.now(); + _previewContactMisses.removeWhere( + (_, timestamp) => now.difference(timestamp) > _previewContactMissTtl, + ); + } + + /// Sync all channels from device + Future syncChannels({int? maxChannels}) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + // Use maxChannels from device info if available, otherwise default to 40 + final channelCount = maxChannels ?? _deviceInfo.maxChannels ?? 40; + debugPrint( + '📻 [Provider] Syncing channels with tolerant per-slot reads (1-${channelCount - 1})...', + ); + + for (int i = 1; i < channelCount; i++) { + try { + await _activeService.getChannel(i); + } catch (e) { + debugPrint( + '⚠️ [Provider] Channel sync skipped slot $i after read failure: $e', + ); + } + } + + debugPrint('✅ [Provider] Channel sync complete'); + } catch (e) { + _error = 'Failed to sync channels: $e'; + notifyListeners(); + } + } + + /// Configure the default public channel (channel 0) with the well-known secret + /// + /// This MUST be called after connecting to the device and before sending any + /// channel messages. Without this configuration, channel messages will fail + /// with ERR_CODE_NOT_FOUND. + /// + /// The public channel uses a well-known pre-shared key that all MeshCore + /// devices use for the default public channel. + Future configureDefaultPublicChannel() async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + debugPrint( + '📻 [Provider] Configuring default public channel (channel 0)', + ); + debugPrint( + ' Using secret: ${MeshCoreConstants.defaultPublicChannelSecret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}', + ); + await _activeService.setChannel( + channelIdx: 0, + channelName: 'Public Channel', + secret: MeshCoreConstants.defaultPublicChannelSecret, + ); + debugPrint('✅ [Provider] Public channel configured successfully'); + } catch (e) { + _error = 'Failed to configure public channel: $e'; + debugPrint('❌ [Provider] Public channel configuration failed: $e'); + debugPrint( + ' This may be normal if the channel is pre-configured in firmware', + ); + notifyListeners(); + rethrow; // Re-throw to notify caller of failure + } + } + + /// Find next available empty channel slot (1-39) + /// + /// Returns the channel index of the first empty slot, or null if all slots are in use. + /// Skips slot 0 (reserved for Public Channel). + /// Callback to get channel info for empty slot detection + /// This should be set by AppProvider to query ChannelsProvider + Function(int channelIdx)? getChannelInfo; + + @visibleForTesting + static bool channelHasConfiguredSecret(Uint8List secret) { + return secret.any((byte) => byte != 0); + } + + @visibleForTesting + static bool isDuplicateChannelName({ + required String requestedName, + required String existingName, + }) { + return requestedName.startsWith('#') && existingName == requestedName; + } + + @visibleForTesting + static int? firstAvailableChannelSlot({ + required Set occupiedIndices, + required int maxChannels, + }) { + for (int i = 1; i < maxChannels; i++) { + if (!occupiedIndices.contains(i)) { + return i; + } + } + return null; + } + + bool _channelSlotIsOccupied(Object channel) { + final channelName = (channel as dynamic).name as String?; + final secret = (channel as dynamic).secret; + final hasConfiguredSecret = + secret is Uint8List && channelHasConfiguredSecret(secret); + return (channelName != null && channelName.isNotEmpty) || + hasConfiguredSecret; + } + + /// Check if a specific channel slot is empty + Future isChannelSlotEmpty(int channelIdx) async { + if (!_activeService.isConnected) { + return false; + } + + try { + // First check if we already have info about this channel + if (getChannelInfo != null) { + final channel = getChannelInfo!(channelIdx); + if (channel != null) { + return !_channelSlotIsOccupied(channel); + } + } + + // If not cached, query the device (awaits the BLE response) + await _activeService.getChannel(channelIdx); + + // Check again after query + if (getChannelInfo != null) { + final channel = getChannelInfo!(channelIdx); + if (channel != null) { + return !_channelSlotIsOccupied(channel); + } + } + + // If we still have no authoritative answer after an on-device query, + // treat the slot as occupied/unknown so we never overwrite a channel + // because of stale or delayed state propagation. + debugPrint( + ' ⚠️ Slot $channelIdx state is still unknown after query; refusing to treat it as empty', + ); + return false; + } catch (e) { + debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e'); + if (_isEmptyChannelQueryError(e)) { + debugPrint( + ' ℹ️ Treating slot $channelIdx as empty because the device reported it was not found', + ); + return true; + } + return false; + } + } + + bool _isEmptyChannelQueryError(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('not found'); + } + + bool _isChannelRefreshTimeoutError(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('0x1f') && message.contains('timed out'); + } + + bool shouldTreatChannelInfoAsDeleted( + int channelIdx, + String channelName, + Uint8List secret, + ) { + final isEmptyInfo = + channelIdx != 0 && + channelName.isEmpty && + secret.every((byte) => byte == 0); + if (!isEmptyInfo) { + return false; + } + + return _pendingDeletedChannelIndices.remove(channelIdx); + } + + Set _syncedOccupiedChannelIndices({required int maxChannels}) { + if (getChannelInfo == null) { + throw Exception('Channel state is unavailable'); + } + + final occupiedIndices = {}; + for (int i = 1; i < maxChannels; i++) { + final channel = getChannelInfo!(i); + if (channel != null && _channelSlotIsOccupied(channel)) { + occupiedIndices.add(i); + } + } + return occupiedIndices; + } + + Future findNextEmptyChannelSlot() async { + if (!_activeService.isConnected) { + throw Exception('Not connected to device'); + } + + try { + debugPrint('🔍 [Provider] Finding next empty channel slot...'); + + // maxChannels from device info, or default to 40 + final maxChannels = _deviceInfo.maxChannels ?? 40; + final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0; + + final occupiedIndices = _syncedOccupiedChannelIndices( + maxChannels: maxChannels, + ); + final emptySlot = firstAvailableChannelSlot( + occupiedIndices: occupiedIndices, + maxChannels: maxChannels, + ); + if (emptySlot != null) { + debugPrint(' ✅ Found empty slot from synced channels: $emptySlot'); + return emptySlot; + } + + debugPrint( + ' ❌ Synced channels report all custom slots occupied ($maxCustomChannels total)', + ); + return null; + } catch (e) { + debugPrint('❌ [Provider] Failed to find empty channel slot: $e'); + rethrow; + } + } + + /// Create a new channel with automatic slot assignment + /// + /// Finds the next available empty channel slot and configures it with the + /// provided name and secret. The secret is converted from an ASCII string + /// to a 16-byte key using MD5 hashing. + /// + /// [channelName] - Name for the channel (max 31 characters) + /// [channelSecret] - ASCII password for the channel (will be hashed to 16 bytes) + /// + /// Throws an exception if all slots are in use or if the channel configuration fails. + Future createChannel({ + required String channelName, + required String channelSecret, + }) async { + if (!_activeService.isConnected) { + throw Exception('Not connected to device'); + } + + try { + debugPrint('📻 [Provider] Creating new channel...'); + debugPrint(' Name: $channelName'); + + final bool isHashChannel = channelName.startsWith('#'); + final secretBytes = isHashChannel + ? _generateHashChannelSecret(channelName) + : _convertSecretToBytes(channelSecret); + + await _createChannelWithSecretBytes( + channelName: channelName, + secretBytes: secretBytes, + isHashChannel: isHashChannel, + ); + } catch (e) { + _error = 'Failed to create channel: $e'; + debugPrint('❌ [Provider] Channel creation failed: $e'); + notifyListeners(); + rethrow; + } + } + + Future _createChannelWithSecretBytes({ + required String channelName, + required List secretBytes, + required bool isHashChannel, + }) async { + final maxChannels = _deviceInfo.maxChannels ?? 40; + + if (getChannelInfo != null) { + for (int i = 1; i < maxChannels; i++) { + final channel = getChannelInfo!(i); + if (channel == null) { + continue; + } + + final existingName = (channel as dynamic).name as String?; + if (existingName != null && + existingName.isNotEmpty && + isDuplicateChannelName( + requestedName: channelName, + existingName: existingName, + )) { + debugPrint( + ' ⚠️ Hash channel "$channelName" already exists in slot $i', + ); + throw Exception( + 'Channel "$channelName" already exists. Hash channels cannot be duplicated.', + ); + } + } + } + + final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0; + final slotIdx = await findNextEmptyChannelSlot(); + if (slotIdx == null) { + throw Exception( + 'All channel slots are in use (maximum $maxCustomChannels custom channels)', + ); + } + + debugPrint( + ' Channel type: ${isHashChannel ? "Hash channel (#)" : "Private channel"}', + ); + debugPrint(' Using empty slot: $slotIdx'); + + try { + await _activeService.setChannel( + channelIdx: slotIdx, + channelName: channelName, + secret: secretBytes, + ); + + // Reconcile local state from the device after the write completes, + // matching the delete flow so channel/contact lists refresh immediately. + await _activeService.getChannel(slotIdx); + } catch (e) { + if (_isChannelRefreshTimeoutError(e)) { + debugPrint( + '⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded', + ); + onChannelInfoReceived?.call( + slotIdx, + channelName, + Uint8List.fromList(secretBytes), + null, + ); + } else { + rethrow; + } + } + + debugPrint('✅ [Provider] Channel created successfully in slot $slotIdx'); + } + + /// Delete a channel and remove it from the UI + /// + /// Clears the channel slot on the device and removes it from both + /// ChannelsProvider and ContactsProvider. The slot becomes available for reuse. + /// + /// [channelIdx] - Channel slot index (1-39). Channel 0 (public) cannot be deleted. + /// + /// Throws an exception if the channel cannot be deleted or if channel 0 is specified. + Future deleteChannel(int channelIdx) async { + if (!_activeService.isConnected) { + throw Exception('Not connected to device'); + } + + if (channelIdx == 0) { + throw Exception('Cannot delete the public channel'); + } + + try { + debugPrint('🗑️ [Provider] Deleting channel in slot $channelIdx...'); + _pendingDeletedChannelIndices.add(channelIdx); + + // Delete channel on device (sets empty name and zeroed secret) + await _activeService.deleteChannel(channelIdx); + + debugPrint( + '✅ [Provider] Channel deleted successfully from slot $channelIdx', + ); + + // Soft-reset local UI state immediately, matching meshcore-open's + // behavior, then reconcile with an on-device refresh below. + onChannelInfoReceived?.call(channelIdx, '', Uint8List(16), null); + + // Refresh channel from device to reconcile UI state. + // BLE commands are processed in order by firmware, so the + // getChannel response reflects the completed deletion. + await _activeService.getChannel(channelIdx); + } catch (e) { + _pendingDeletedChannelIndices.remove(channelIdx); + _error = 'Failed to delete channel: $e'; + debugPrint('❌ [Provider] Channel deletion failed: $e'); + notifyListeners(); + rethrow; + } + } + + Future setChannelSlot({ + required int channelIdx, + required String channelName, + required Uint8List secret, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.setChannel( + channelIdx: channelIdx, + channelName: channelName, + secret: secret, + ); + await _activeService.getChannel(channelIdx); + } catch (e) { + _error = 'Failed to set channel: $e'; + notifyListeners(); + rethrow; + } + } + + /// Generate secret for hash channel using SHA256 + /// Same algorithm as Channel model for consistency + /// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16] + List _generateHashChannelSecret(String channelName) { + final bytes = utf8.encode(channelName); + final digest = sha256.convert(bytes); + return digest.bytes.sublist(0, 16); + } + + /// Convert ASCII secret string to 16-byte key using MD5 hash + /// Used for private channels with explicit secrets + List _convertSecretToBytes(String asciiSecret) { + // Use MD5 hash to convert any length ASCII string to exactly 16 bytes + // This provides a deterministic and secure way to generate channel keys + return md5.convert(utf8.encode(asciiSecret)).bytes; + } + + /// Add or update a contact on the companion radio + /// + /// This manually adds a contact to the radio's internal contact table. + /// Useful when a room contact was deleted or never advertised yet. + Future addOrUpdateContact(Contact contact) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + _error = null; + await _activeService.addOrUpdateContact(contact); + } catch (e) { + _error = 'Failed to add/update contact: $e'; + notifyListeners(); + } + } + + Future importContactAdvert(Uint8List contactAdvertFrame) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + _error = null; + await _activeService.importContact(contactAdvertFrame); + } catch (e) { + _error = 'Failed to import contact: $e'; + notifyListeners(); + } + } + + /// Send text message to contact + /// + /// Returns true if the message was successfully sent to the BLE service. + /// Note: This doesn't mean the message was delivered over the mesh network, + /// only that it was queued on the companion radio. + /// + /// [messageId] - optional message ID to track delivery status + /// [contact] - optional contact object for path status logging + /// [retryAttempt] - retry attempt number (0 = first send, >0 = retries) + Future sendTextMessage({ + required Uint8List contactPublicKey, + required String text, + String? messageId, + Contact? contact, + int retryAttempt = 0, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return false; + } + + var effectiveContact = contact; + effectiveContact ??= resolveContactForDmCallback?.call(contactPublicKey); + if (messageId != null && + effectiveContact != null && + prepareDirectMessageSendCallback != null) { + effectiveContact = await prepareDirectMessageSendCallback!( + messageId: messageId, + contact: effectiveContact, + retryAttempt: retryAttempt, + ); + } + + // CRITICAL: Check firmware ACK limit (8 max in circular buffer) + // Rate limit at 7 to stay under the limit + if (_messageDeliveryTracker.shouldRateLimit) { + final pendingCount = _messageDeliveryTracker.pendingCount; + debugPrint( + '⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)', + ); + debugPrint( + '⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmation...', + ); + + // Wait for a slot to free up (delivery/timeout), max 500ms + await _messageDeliveryTracker.waitForSlot(); + + if (_messageDeliveryTracker.shouldRateLimit) { + debugPrint( + '⚠️ Still at limit after wait - proceeding anyway (may lose ACK tracking)', + ); + } + } + + try { + // Log path status and retry info + if (effectiveContact != null) { + if (retryAttempt > 0) { + debugPrint( + '🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)', + ); + } else { + debugPrint( + '📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}', + ); + } + debugPrint(' Type: ${effectiveContact.type.displayName}'); + debugPrint(' Path status: ${effectiveContact.routeSummary}'); + if (effectiveContact.routeHasPath) { + debugPrint( + ' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)', + ); + } else { + debugPrint(' ⚠️ No path available - will use flood mode'); + } + } else if (retryAttempt > 0) { + debugPrint( + '🔄 [ConnectionProvider] Sending message (retry $retryAttempt)', + ); + } + + // Track pending operation for auto-recovery (if contact not found in radio) + if (effectiveContact != null) { + final operationId = contactPublicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); + _pendingSendOperations[operationId] = _PendingSendOperation( + contactPublicKey: contactPublicKey, + text: text, + messageId: messageId, + contact: effectiveContact, + retryAttempt: retryAttempt, + ); + debugPrint( + ' 📝 Tracked pending operation for auto-recovery: $operationId', + ); + } + + // IMPORTANT: Track pending message BEFORE sending to avoid race condition + // The SENT response can arrive so quickly that if we track after sending, + // the callback will fire before we add the message ID to the queue. + // + // NOTE: For grouped messages, we no longer need complex contact-keyed tracking. + // The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping. + // We still track here for the SENT response callback to work. + if (messageId != null) { + _messageDeliveryTracker.trackPendingDirectMessage( + messageId, + contactPublicKey, + ); + debugPrint(' 📝 Tracked pending message: $messageId'); + } + + // Send the message with retry attempt info + await _activeService.sendTextMessage( + contactPublicKey: contactPublicKey, + text: text, + attempt: retryAttempt, + ); + + // Clear pending operation — any ERR_CODE_NOT_FOUND has already been + // handled synchronously by onContactNotFound before sendTextMessage returns. + if (effectiveContact != null) { + final operationId = contactPublicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); + _pendingSendOperations.remove(operationId); + } + + return true; + } catch (e) { + _error = 'Failed to send message: $e'; + notifyListeners(); + return false; + } + } + + /// Send channel message + /// + /// [messageId] - optional message ID to track delivery status + /// Note: Channel messages are ephemeral (not persisted), so they're marked + /// as "sent" immediately upon receiving OK response from the device. + Future sendChannelMessage({ + required int channelIdx, + required String text, + String? messageId, + Uint8List? floodScopeKey, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + debugPrint('📨 [ConnectionProvider] sendChannelMessage called:'); + 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'); + debugPrint( + ' Checking messageId: ${messageId != null ? "Present ($messageId)" : "NULL"}', + ); + + // Channel messages are ephemeral (not persisted) - mark as "sent" immediately + // They don't have ACK/TAG mechanism like direct messages + if (messageId != null) { + debugPrint('✅ [ConnectionProvider] Channel message sent successfully'); + debugPrint(' Message ID: $messageId'); + debugPrint(' onMessageSent callback exists: ${onMessageSent != null}'); + + // Track for echo detection + // The BLE handler will capture the packet via LOG_RX_DATA and associate it + debugPrint(' Calling trackSentChannelMessage...'); + _activeService.trackSentChannelMessage( + messageId, + channelIdx: channelIdx, + plainText: text, + ); + debugPrint(' trackSentChannelMessage completed'); + + // Use a dummy ACK tag (0) and timeout (0) for channel messages + // This will trigger the callback to mark the message as "sent" + debugPrint(' Calling onMessageSent callback...'); + onMessageSent?.call(messageId, 0, 0); + debugPrint(' onMessageSent callback completed'); + } + } catch (e) { + _error = 'Failed to send channel message: $e'; + notifyListeners(); + } + } + + /// Send a raw binary voice packet directly to a contact (cmdSendRawData, code 25). + /// Only works for contacts with a known direct route (outPathLen >= 0). + /// + /// Callers pass the contact's *encoded* route descriptor ([routeEncodedPathLen]) + /// and encoded [outPath]. The `CMD_SEND_RAW_DATA` handler in companion firmware + /// v1.15 misinterprets encoded descriptors as a literal path-byte count, so we + /// normalise to the legacy raw path format (literal hop count + one byte per + /// hop) before handing the frame to the transport. See + /// [ContactRouteCodec.toLegacyRawPath]. + Future sendRawVoicePacket({ + required Uint8List contactPath, + required int contactPathLen, + required Uint8List payload, + }) async { + if (!_activeService.isConnected) return; + final legacy = ContactRouteCodec.toLegacyRawPath(contactPathLen, contactPath); + await _activeService.sendRawVoicePacket( + contactPathLen: legacy.length, + contactPath: Uint8List.fromList(legacy.path), + payload: payload, + ); + } + + /// Send a raw private zero-hop payload. + /// + /// This wraps the raw custom transport using an empty path to match the + /// firmware's private multicast behavior. + Future sendRawPrivateMulticast(Uint8List payload) async { + if (!_activeService.isConnected) return; + await _activeService.sendRawVoicePacket( + contactPathLen: 0, + contactPath: Uint8List(0), + payload: payload, + ); + } + + /// Clear the flood scope on the device (firmware v8+). + Future clearFloodScope() async { + if (!_activeService.isConnected) return; + try { + await _activeService.clearFloodScope(); + } catch (e) { + debugPrint('⚠️ [ConnectionProvider] Failed to clear flood scope: $e'); + } + } + + Future 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). + /// Depending on device firmware version, the response will be either: + /// - PUSH_CODE_TELEMETRY_RESPONSE (0x8B) - older firmware + /// - PUSH_CODE_BINARY_RESPONSE (0x8C) - newer firmware + /// + /// Both response types are handled via callbacks: + /// - onTelemetryReceived (for 0x8B) + /// - onBinaryResponse (for 0x8C) + /// + /// The app properly handles BOTH response types, so this method is NOT + /// deprecated and should continue to be used for telemetry requests. + /// + /// [zeroHop] - if true, only direct connection (no mesh forwarding) + Future requestTelemetry( + Uint8List contactPublicKey, { + bool zeroHop = false, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.requestTelemetry(contactPublicKey, zeroHop: zeroHop); + } catch (e) { + _error = 'Failed to request telemetry: $e'; + notifyListeners(); + } + } + + /// Smart ping with automatic fallback to flooding + /// + /// Sends a telemetry request (ping) to a contact, and if no response is + /// received within timeout, automatically retries with flooding mode. + /// + /// Returns a PingResult with information about the response. + /// + /// [contact] - the contact to ping (used to determine if path exists) + /// [onRetryWithFlooding] - optional callback when fallback to flooding occurs + Future smartPing({ + required Uint8List contactPublicKey, + required bool hasPath, + Function()? onRetryWithFlooding, + }) async { + final pingKey = _publicKeyToHex(contactPublicKey); + final pendingPing = _pendingSmartPings[pingKey]; + if (pendingPing != null) { + debugPrint('ℹ️ [Provider] Joining in-flight ping for $pingKey'); + return pendingPing; + } + + final future = _isSelfPublicKey(contactPublicKey) + ? _runSelfTelemetryPing(contactPublicKey) + : _runSmartPing( + contactPublicKey: contactPublicKey, + hasPath: hasPath, + onRetryWithFlooding: onRetryWithFlooding, + ); + _pendingSmartPings[pingKey] = future; + notifyListeners(); + + try { + return await future; + } finally { + _pendingSmartPings.remove(pingKey); + notifyListeners(); + } + } + + Future _runSmartPing({ + required Uint8List contactPublicKey, + required bool hasPath, + Function()? onRetryWithFlooding, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return PingResult(success: false, usedFlooding: false, timedOut: true); + } + + // First attempt: Use zeroHop (direct) if we have a path, otherwise use flooding + final bool firstAttemptDirect = hasPath; + + try { + // Track the ping request + final pingFuture = _pingTracker.trackPing( + publicKey: contactPublicKey, + wasDirectAttempt: firstAttemptDirect, + ); + + // Send the ping + await _activeService.requestTelemetry( + contactPublicKey, + zeroHop: firstAttemptDirect, + ); + + // Wait for response or timeout + final bool gotResponse = await pingFuture; + + if (gotResponse) { + // Success on first attempt + return PingResult( + success: true, + usedFlooding: !firstAttemptDirect, + timedOut: false, + ); + } + + // First attempt timed out - retry with flooding if first was direct + if (firstAttemptDirect) { + debugPrint( + '⚠️ [Provider] Ping timeout on direct attempt, retrying with flooding...', + ); + onRetryWithFlooding?.call(); + + // Track the retry + final retryFuture = _pingTracker.trackPing( + publicKey: contactPublicKey, + wasDirectAttempt: false, + ); + + // Retry with flooding. + await _activeService.requestTelemetry(contactPublicKey, zeroHop: false); + + // Wait for response or timeout + final bool gotRetryResponse = await retryFuture; + + return PingResult( + success: gotRetryResponse, + usedFlooding: true, + timedOut: !gotRetryResponse, + retriedWithFlooding: true, + ); + } + + // First attempt was already flooding and it timed out + return PingResult(success: false, usedFlooding: true, timedOut: true); + } catch (e) { + _error = 'Failed to ping contact: $e'; + notifyListeners(); + return PingResult(success: false, usedFlooding: false, timedOut: true); + } + } + + Future _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 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(); + _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: + /// - Telemetry data (use MeshCoreConstants.binaryReqGetTelemetryData) + /// - Average/min/max telemetry (use MeshCoreConstants.binaryReqGetAvgMinMax) + /// - Access list (use MeshCoreConstants.binaryReqGetAccessList) + /// - Neighbors list (use MeshCoreConstants.binaryReqGetNeighbours) + /// + /// Response arrives via onBinaryResponse callback with matching tag. + /// + /// Example - request telemetry: + /// ```dart + /// connectionProvider.onBinaryResponse = (prefix, tag, data) { + /// // Parse telemetry data (Cayenne LPP format) + /// final telemetry = CayenneLppParser.parse(data); + /// }; + /// await connectionProvider.requestBinary( + /// contactPublicKey: contact.publicKey, + /// requestType: MeshCoreConstants.binaryReqGetTelemetryData, + /// ); + /// ``` + Future requestBinary({ + required Uint8List contactPublicKey, + required int requestType, + Uint8List? additionalParams, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + // Build request data: request type byte + optional params + final requestData = Uint8List.fromList([ + requestType, + if (additionalParams != null) ...additionalParams, + ]); + + await _activeService.sendBinaryRequest( + contactPublicKey: contactPublicKey, + requestData: requestData, + ); + } catch (e) { + _error = 'Failed to send binary request: $e'; + notifyListeners(); + } + } + + Future discoverNodeType({ + required int advertType, + bool prefixOnly = false, + int since = 0, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + final random = Random.secure(); + final tagBytes = Uint8List.fromList( + List.generate(4, (_) => random.nextInt(256)), + ); + final payload = BytesBuilder(copy: false) + ..addByte(_controlTypeNodeDiscoverReq | (prefixOnly ? 0x01 : 0x00)) + ..addByte(1 << advertType) + ..add(tagBytes) + ..add([ + since & 0xFF, + (since >> 8) & 0xFF, + (since >> 16) & 0xFF, + (since >> 24) & 0xFF, + ]); + + try { + await _activeService.sendControlData(payload.toBytes()); + } catch (e) { + _error = 'Failed to send node discovery request: $e'; + notifyListeners(); + } + } + + /// Get device time from companion radio to detect clock drift + Future getDeviceTime() async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.getDeviceTime(); + } catch (e) { + _error = 'Failed to get device time: $e'; + notifyListeners(); + } + } + + /// Set device time to current time + Future syncDeviceTime() async { + if (!_activeService.isConnected) return; + + try { + await _activeService.setDeviceTime(); + } catch (e) { + _error = 'Failed to sync time: $e'; + notifyListeners(); + } + } + + Future factoryResetDevice() async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + _error = null; + await _activeService.factoryReset(); + } catch (e) { + _error = 'Failed to wipe device data: $e'; + notifyListeners(); + } + } + + /// Set advertised name + Future setAdvertName(String name) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.setAdvertName(name); + } catch (e) { + _error = 'Failed to set name: $e'; + notifyListeners(); + } + } + + /// Set advertised position + Future setAdvertLatLon({ + required double latitude, + required double longitude, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.setAdvertLatLon( + latitude: latitude, + longitude: longitude, + ); + } catch (e) { + _error = 'Failed to set position: $e'; + notifyListeners(); + } + } + + /// Send self advertisement to mesh network + /// + /// Broadcasts the device's current advertisement data (name, location, etc.) + /// to the mesh network. Use this after updating position or name to notify + /// other nodes of the change. + /// + /// [floodMode] - if true, broadcast to entire mesh (default for SAR ops) + /// if false, only send to direct neighbors (zero-hop) + Future sendSelfAdvert({bool floodMode = true}) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + if (_isAdvertInProgress) return; + // Throttle rapid advert requests + final now = DateTime.now(); + if (_lastAdvertRequestedAt != null) { + final elapsed = now.difference(_lastAdvertRequestedAt!); + if (elapsed < _minAdvertInterval) { + final wait = _minAdvertInterval - elapsed; + await Future.delayed(wait); + } + } + _isAdvertInProgress = true; + await _activeService.sendSelfAdvert(floodMode: floodMode); + _lastAdvertRequestedAt = DateTime.now(); + } catch (e) { + _error = 'Failed to send advertisement: $e'; + notifyListeners(); + } finally { + _isAdvertInProgress = false; + } + } + + /// Set radio parameters + Future setRadioParams({ + required int frequency, + required int bandwidth, + required int spreadingFactor, + required int codingRate, + bool? repeat, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.setRadioParams( + frequency: frequency, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + repeat: repeat, + ); + } catch (e) { + _error = 'Failed to set radio params: $e'; + notifyListeners(); + } + } + + /// Request the list of allowed repeat frequency ranges from the device (firmware v9+) + Future getAllowedRepeatFreq() async { + if (!_activeService.isConnected) return; + try { + await _activeService.getAllowedRepeatFreq(); + } catch (e) { + debugPrint('Failed to get allowed repeat freq: $e'); + } + } + + /// Set transmit power + Future setTxPower(int powerDbm) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.setTxPower(powerDbm); + } catch (e) { + _error = 'Failed to set TX power: $e'; + notifyListeners(); + } + } + + /// Set other parameters (telemetry modes, advert location policy) + Future setOtherParams({ + required int manualAddContacts, + required int telemetryModes, + required int advertLocationPolicy, + int multiAcks = 0, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.setOtherParams( + manualAddContacts: manualAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: advertLocationPolicy, + multiAcks: multiAcks, + ); + _deviceInfo = _deviceInfo.copyWith( + telemetryModes: telemetryModes, + advertLocPolicy: advertLocationPolicy, + multiAcks: multiAcks, + ); + notifyListeners(); + } catch (e) { + _error = 'Failed to set other params: $e'; + notifyListeners(); + } + } + + Future getAutoaddConfig() async { + if (_supportsAutoaddConfig == false) { + return; + } + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + final config = await _activeService.getAutoaddConfig(); + _supportsAutoaddConfig = true; + _deviceInfo = _deviceInfo.copyWith( + autoAddUsers: config['autoAddUsers'] as bool?, + autoAddRepeaters: config['autoAddRepeaters'] as bool?, + autoAddRoomServers: config['autoAddRoomServers'] as bool?, + autoAddSensors: config['autoAddSensors'] as bool?, + autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?, + autoAddMaxHops: config['autoAddMaxHops'] as int?, + ); + notifyListeners(); + } catch (e) { + if (_isUnsupportedAutoaddConfigError(e)) { + _supportsAutoaddConfig = false; + _deviceInfo = _deviceInfo.copyWith( + autoAddUsers: null, + autoAddRepeaters: null, + autoAddRoomServers: null, + autoAddSensors: null, + autoAddOverwriteOldest: null, + autoAddMaxHops: null, + ); + notifyListeners(); + return; + } + if (_isTransientAutoaddConfigError(e)) { + debugPrint('Ignoring transient auto-add config error: $e'); + return; + } + _error = 'Failed to get auto-add config: $e'; + notifyListeners(); + } + } + + Future setAutoaddConfig({ + required bool autoAddUsers, + required bool autoAddRepeaters, + required bool autoAddRoomServers, + required bool autoAddSensors, + required bool overwriteOldest, + int maxHops = 0, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.setAutoaddConfig( + autoAddUsers: autoAddUsers, + autoAddRepeaters: autoAddRepeaters, + autoAddRoomServers: autoAddRoomServers, + autoAddSensors: autoAddSensors, + overwriteOldest: overwriteOldest, + maxHops: maxHops, + ); + _deviceInfo = _deviceInfo.copyWith( + autoAddUsers: autoAddUsers, + autoAddRepeaters: autoAddRepeaters, + autoAddRoomServers: autoAddRoomServers, + autoAddSensors: autoAddSensors, + autoAddOverwriteOldest: overwriteOldest, + autoAddMaxHops: maxHops, + ); + notifyListeners(); + } catch (e) { + _error = 'Failed to set auto-add config: $e'; + notifyListeners(); + } + } + + Future 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 exportContactUrl(Uint8List? publicKey) async { + if (!_activeService.isConnected) return null; + try { + final frame = await _activeService.exportContact(publicKey); + final hex = frame.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return 'meshcore://$hex'; + } catch (e) { + debugPrint('⚠️ [Provider] exportContact failed: $e'); + _error = 'Failed to export contact: $e'; + notifyListeners(); + return null; + } + } + + /// Read custom variables from device (GPS mode, sensor settings, etc.) + Future> getCustomVars() async { + if (!_activeService.isConnected) { + return {}; + } + try { + return await _activeService.getCustomVars(); + } catch (e) { + debugPrint('⚠️ [Provider] getCustomVars failed: $e'); + return {}; + } + } + + /// Set a custom variable on the device + Future setCustomVar(String key, String value) async { + if (!_activeService.isConnected) return; + try { + await _activeService.setCustomVar(key, value); + } catch (e) { + _error = 'Failed to set $key: $e'; + notifyListeners(); + } + } + + Future refreshDeviceInfo() async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + // The device query command triggers a SelfInfo response + await _activeService.refreshDeviceInfo(); + if (_supportsAutoaddConfig != false) { + unawaited(getAutoaddConfig()); + } + try { + await _activeService.getAllowedRepeatFreq(); + } catch (_) { + // Older firmware may not expose repeat frequency ranges. + } + } catch (e) { + _error = 'Failed to refresh device info: $e'; + notifyListeners(); + } + } + + bool _isUnsupportedAutoaddConfigError(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('unsupported command') || + message.contains('unsupported'); + } + + bool _isTransientAutoaddConfigError(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('illegal argument'); + } + + /// Request battery and storage information + /// + /// Queries the companion radio for: + /// - Battery voltage in millivolts + /// - Used storage in KB (if available) + /// - 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 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 getBatteryAndStorage() async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.getBatteryAndStorage(); + } catch (e) { + _error = 'Failed to get battery and storage: $e'; + notifyListeners(); + } + } + + /// Sync messages from device queue + /// Call this repeatedly until no more messages are available + Future syncNextMessage() async { + // Prevent re-entrancy and too-fast triggers + if (_isSyncingMessages) { + // Another sync (single or loop) is in progress + return false; + } + + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return false; + } + + try { + // Enforce a small gap between consecutive requests + final now = DateTime.now(); + if (_lastSyncNextRequestedAt != null) { + final elapsed = now.difference(_lastSyncNextRequestedAt!); + if (elapsed < _minSyncNextInterval) { + final remaining = _minSyncNextInterval - elapsed; + await Future.delayed(remaining); + } + } + + _isSyncingMessages = true; + await _activeService.syncNextMessage(); + _lastSyncNextRequestedAt = DateTime.now(); + return true; + } catch (e) { + _error = 'Failed to sync message: $e'; + notifyListeners(); + return false; + } finally { + _isSyncingMessages = false; + } + } + + /// Sync all waiting messages from device + Future syncAllMessages({bool force = false}) async { + if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) { + _pendingAutomaticMessageSync = true; + debugPrint( + '⏸️ [Provider] Message sync deferred until connection bootstrap completes', + ); + return 0; + } + if (_isSyncingMessages) { + // Already syncing; avoid overlapping loops + _syncRequestedWhileBusy = true; + return 0; + } + + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return 0; + } + + int totalCount = 0; + + try { + _pendingAutomaticMessageSync = false; + _isSyncingMessages = true; + do { + _syncRequestedWhileBusy = false; + _noMoreMessages = false; // Reset flag per pass + int passCount = 0; + debugPrint('🔄 [Provider] Starting message sync loop...'); + debugPrint(' Initial _noMoreMessages state: $_noMoreMessages'); + + // Keep syncing until we get NoMoreMessages response + // The device will send ContactMsgRecv or ChannelMsgRecv responses + // until it sends NoMoreMessages + for (int i = 0; i < 100; i++) { + // Safety limit + // Check flag BEFORE sending (not after) + if (_noMoreMessages) { + debugPrint( + '✅ [Provider] Message sync complete - NoMoreMessages flag set after $passCount requests', + ); + break; + } + + debugPrint( + '📤 [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE', + ); + + // Create new completer for this request + _syncResponseCompleter = Completer(); + + // Respect the minimum interval between requests + final now = DateTime.now(); + if (_lastSyncNextRequestedAt != null) { + final elapsed = now.difference(_lastSyncNextRequestedAt!); + if (elapsed < _minSyncNextInterval) { + final remaining = _minSyncNextInterval - elapsed; + await Future.delayed(remaining); + } + } + + await _activeService.syncNextMessage(); + _lastSyncNextRequestedAt = DateTime.now(); + passCount++; + totalCount++; + + // Wait for response (true = message received, false = no more messages) + // Timeout after 2 seconds to prevent hanging + final hasMore = await _syncResponseCompleter!.future.timeout( + const Duration(seconds: 2), + onTimeout: () { + debugPrint('⚠️ [Provider] Sync timeout - no response after 2s'); + return false; + }, + ); + + debugPrint( + ' After iteration ${i + 1}: hasMore=$hasMore, _noMoreMessages=$_noMoreMessages', + ); + + if (!hasMore) { + debugPrint(' ✅ No more messages available, stopping sync'); + break; + } + } + + if (!_noMoreMessages && passCount >= 100) { + debugPrint( + '⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages', + ); + } + + if (_syncRequestedWhileBusy) { + debugPrint( + '↻ [Provider] MSG_WAITING received during sync; running another pass', + ); + } + } while (_syncRequestedWhileBusy && _activeService.isConnected); + + debugPrint( + '🏁 [Provider] Message sync finished: sent $totalCount sync requests, _noMoreMessages=$_noMoreMessages', + ); + return totalCount; + } catch (e) { + debugPrint('❌ [Provider] Failed to sync messages: $e'); + _error = 'Failed to sync messages: $e'; + notifyListeners(); + return totalCount; + } finally { + _isSyncingMessages = false; + _syncResponseCompleter = null; + } + } + + bool get hasPendingAutomaticMessageSync => _pendingAutomaticMessageSync; + + void clearPendingAutomaticMessageSync() { + _pendingAutomaticMessageSync = false; + } + + /// Login to a room or repeater + /// + /// Sends login request with password. Results will be delivered via + /// onLoginSuccess or onLoginFail callbacks. + /// + /// Example usage: + /// ```dart + /// connectionProvider.onLoginSuccess = (pkPrefix, perms, isAdmin, tag) { + /// debugPrint('Successfully logged in to room!'); + /// }; + /// connectionProvider.onLoginFail = (pkPrefix) { + /// debugPrint('Login failed - incorrect password'); + /// }; + /// await connectionProvider.loginToRoom( + /// roomPublicKey: contact.publicKey, + /// password: 'secret123', + /// ); + /// ``` + Future loginToRoom({ + required Uint8List roomPublicKey, + required String password, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + if (_isLoginInProgress) return; + // Throttle rapid login attempts + final now = DateTime.now(); + if (_lastLoginRequestedAt != null) { + final elapsed = now.difference(_lastLoginRequestedAt!); + if (elapsed < _minLoginInterval) { + final wait = _minLoginInterval - elapsed; + await Future.delayed(wait); + } + } + _isLoginInProgress = true; + await _activeService.loginToRoom( + roomPublicKey: roomPublicKey, + password: password, + ); + _lastLoginRequestedAt = DateTime.now(); + } catch (e) { + _error = 'Failed to send login request: $e'; + notifyListeners(); + } finally { + _isLoginInProgress = false; + } + } + + /// Request status from repeater or sensor node + /// + /// Sends a status request to query operational status of a node. + /// Results will be delivered via onStatusResponse callback. + /// + /// Example usage: + /// ```dart + /// connectionProvider.onStatusResponse = (publicKeyPrefix, statusData) { + /// debugPrint('Status from node: ${utf8.decode(statusData)}'); + /// }; + /// await connectionProvider.requestStatus(repeaterContact.publicKey); + /// ``` + Future requestStatus(Uint8List contactPublicKey) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + if (_isStatusRequestInProgress) return; + // Throttle rapid status requests + final now = DateTime.now(); + if (_lastStatusRequestedAt != null) { + final elapsed = now.difference(_lastStatusRequestedAt!); + if (elapsed < _minStatusInterval) { + final wait = _minStatusInterval - elapsed; + await Future.delayed(wait); + } + } + _isStatusRequestInProgress = true; + await _activeService.sendStatusRequest(contactPublicKey); + _lastStatusRequestedAt = DateTime.now(); + } catch (e) { + _error = 'Failed to send status request: $e'; + notifyListeners(); + } finally { + _isStatusRequestInProgress = false; + } + } + + Future<({int tag, int suggestedTimeoutMs})?> sendAnonRequest({ + required Uint8List contactPublicKey, + required Uint8List requestData, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return null; + } + + try { + return await _activeService.sendAnonRequest( + contactPublicKey: contactPublicKey, + requestData: requestData, + ); + } catch (e) { + _error = 'Failed to send anonymous request: $e'; + notifyListeners(); + return null; + } + } + + /// Reset routing path for a contact + /// + /// Clears the learned path to a contact, forcing the next message to use + /// flood routing to discover a new route. Useful when: + /// - A mobile repeater has moved and the path is broken + /// - You want to find a better/shorter route + /// - Direct messages are timing out due to path issues + /// + /// After calling this, the device will automatically fall back to flood mode + /// for the next message to this contact, and learn a new path from the response. + Future resetPath(Uint8List contactPublicKey) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + _error = null; + await _activeService.resetPath(contactPublicKey); + } catch (e) { + _error = 'Failed to reset path: $e'; + notifyListeners(); + } + } + + Future setContactRoute( + Contact contact, { + required int signedEncodedPathLen, + required Uint8List paddedPathBytes, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + _error = null; + final updatedContact = contact.copyWith( + outPathLen: signedEncodedPathLen, + outPath: Uint8List.fromList(paddedPathBytes), + ); + await _activeService.addOrUpdateContact(updatedContact); + } catch (e) { + _error = 'Failed to set route: $e'; + notifyListeners(); + rethrow; + } + } + + /// 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 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. + /// The contact will no longer appear in the contact list and all + /// routing information will be cleared. + Future removeContact(Uint8List contactPublicKey) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + throw Exception(_error); + } + + try { + await _activeService.removeContact(contactPublicKey); + } catch (e) { + _error = 'Failed to remove contact: $e'; + notifyListeners(); + rethrow; + } + } + + /// Clear error message + void clearError() { + _error = null; + notifyListeners(); + } + + /// Get login state for a room by public key prefix + RoomLoginState? getRoomLoginState(Uint8List publicKeyPrefix) { + return _roomLoginManager.getRoomLoginState(publicKeyPrefix); + } + + /// Check if logged into a specific room + bool isLoggedIntoRoom(Uint8List publicKeyPrefix) { + return _roomLoginManager.isLoggedIntoRoom(publicKeyPrefix); + } + + @override + void dispose() { + _rxActivityTimer?.cancel(); + _txActivityTimer?.cancel(); + _stopAckCleanupTimer(); + final disconnectTransport = _serialDisconnectTransport; + if (disconnectTransport != null) { + unawaited(disconnectTransport()); + _serialDisconnectTransport = null; + } + _serialService?.dispose(); + _bleService.dispose(); + _tcpService?.dispose(); + super.dispose(); + } +} diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart new file mode 100644 index 0000000..bef1adf --- /dev/null +++ b/lib/providers/contacts_provider.dart @@ -0,0 +1,1914 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; +import '../models/contact.dart'; +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'; + +class PendingAdvert { + final Uint8List publicKey; + final DateTime receivedAt; + final String? advName; + final int? typeValue; + final int? flags; + final int? lastAdvert; + final int? advLat; + final int? advLon; + final int? signedEncodedPathLen; + final Uint8List? paddedPathBytes; + final int? rxRssiDbm; + final int? rxSnrRaw; + final int? repeaterBatteryMv; + final int? repeaterQueueLen; + final int? repeaterLastRssi; + final int? repeaterLastSnrRaw; + final int? repeaterUptimeSecs; + + const PendingAdvert({ + required this.publicKey, + required this.receivedAt, + this.advName, + this.typeValue, + this.flags, + this.lastAdvert, + this.advLat, + this.advLon, + this.signedEncodedPathLen, + this.paddedPathBytes, + this.rxRssiDbm, + this.rxSnrRaw, + this.repeaterBatteryMv, + this.repeaterQueueLen, + this.repeaterLastRssi, + this.repeaterLastSnrRaw, + this.repeaterUptimeSecs, + }); + + String get publicKeyHex => + publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + + String get shortDisplayKey { + final prefix = publicKey.length >= 6 ? publicKey.sublist(0, 6) : publicKey; + return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + } + + PendingAdvert copyWith({ + Uint8List? publicKey, + DateTime? receivedAt, + String? advName, + int? typeValue, + int? flags, + int? lastAdvert, + int? advLat, + int? advLon, + int? signedEncodedPathLen, + Uint8List? paddedPathBytes, + int? rxRssiDbm, + int? rxSnrRaw, + int? repeaterBatteryMv, + int? repeaterQueueLen, + int? repeaterLastRssi, + int? repeaterLastSnrRaw, + int? repeaterUptimeSecs, + }) { + return PendingAdvert( + publicKey: publicKey ?? this.publicKey, + receivedAt: receivedAt ?? this.receivedAt, + advName: advName ?? this.advName, + typeValue: typeValue ?? this.typeValue, + flags: flags ?? this.flags, + lastAdvert: lastAdvert ?? this.lastAdvert, + advLat: advLat ?? this.advLat, + advLon: advLon ?? this.advLon, + signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen, + paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes, + rxRssiDbm: rxRssiDbm ?? this.rxRssiDbm, + rxSnrRaw: rxSnrRaw ?? this.rxSnrRaw, + repeaterBatteryMv: repeaterBatteryMv ?? this.repeaterBatteryMv, + repeaterQueueLen: repeaterQueueLen ?? this.repeaterQueueLen, + repeaterLastRssi: repeaterLastRssi ?? this.repeaterLastRssi, + repeaterLastSnrRaw: repeaterLastSnrRaw ?? this.repeaterLastSnrRaw, + repeaterUptimeSecs: repeaterUptimeSecs ?? this.repeaterUptimeSecs, + ); + } + + double? get repeaterBatteryPercent { + if (repeaterBatteryMv == null) return null; + final voltage = repeaterBatteryMv! / 1000.0; + if (voltage <= 3.0) return 0.0; + if (voltage >= 4.2) return 100.0; + return ((voltage - 3.0) / 1.2) * 100.0; + } + + double? get repeaterLastSnr => + repeaterLastSnrRaw == null ? null : repeaterLastSnrRaw! / 4.0; + + double? get rxSnr => rxSnrRaw == null ? null : rxSnrRaw! / 4.0; +} + +class _RetainedRoute { + final int signedEncodedPathLen; + final Uint8List paddedPathBytes; + + const _RetainedRoute({ + required this.signedEncodedPathLen, + required this.paddedPathBytes, + }); +} + +/// Contacts Provider - manages contact list and telemetry +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 _contacts = {}; + final List _savedContactGroups = []; + final Map _pendingAdverts = {}; + final Map _estimatedLocations = {}; + final Map> _rssiObservations = {}; + final Map _retainedContactsForSync = {}; + final ContactStorageService _storageService = ContactStorageService(); + bool _isInitialized = false; + bool _isPersisting = false; + bool _persistRequested = false; + bool _isPersistingPendingAdverts = false; + bool _persistPendingAdvertsRequested = false; + String? _storageNamespace; + String? _selfPublicKeyHex; + ContactTelemetry? _selfTelemetry; + + // Add default public channel on initialization + ContactsProvider() + : _storageNamespace = ProfileStorageScope.effectiveNamespace { + _ensurePublicChannelExists(); + } + + bool get isInitialized => _isInitialized; + String? get storageNamespace => _storageNamespace; + + /// Initialize and load persisted contacts at app startup + /// This loads contacts without filtering, allowing offline viewing + Future initializeEarly() async { + if (_isInitialized) return; + + await _loadFromStorage(); + } + + Future reloadFromStorage({ + String? namespace, + Uint8List? devicePublicKey, + }) async { + _storageNamespace = namespace; + _setSelfDevicePublicKey(devicePublicKey); + await _loadFromStorage(force: true, devicePublicKey: devicePublicKey); + } + + Future persistNow() async { + await _storageService.saveContacts( + _contactsForStorage(), + namespace: _storageNamespace, + ); + await _storageService.saveContactGroups( + _savedContactGroups, + namespace: _storageNamespace, + ); + await _storageService.savePendingAdverts( + _pendingAdverts.values.map(_pendingAdvertToJson).toList(), + namespace: _storageNamespace, + ); + } + + Future cloneCurrentStorageTo(String? namespace) async { + await _storageService.saveContacts( + _contactsForStorage(), + namespace: namespace, + ); + await _storageService.saveContactGroups( + _savedContactGroups, + namespace: namespace, + ); + await _storageService.savePendingAdverts( + _pendingAdverts.values.map(_pendingAdvertToJson).toList(), + namespace: namespace, + ); + } + + Future _loadFromStorage({ + bool force = false, + Uint8List? devicePublicKey, + }) async { + if (_isInitialized && !force) return; + + try { + _resetInMemoryState(); + debugPrint( + '📦 [ContactsProvider] Early loading persisted contacts (no filtering)...', + ); + final storedContacts = await _storageService.loadContacts( + namespace: _storageNamespace, + ); + final storedGroups = await _storageService.loadContactGroups( + namespace: _storageNamespace, + ); + final storedPendingAdverts = await _storageService.loadPendingAdverts( + namespace: _storageNamespace, + ); + + // Add stored contacts (excluding any with all-zeros public key) + const publicChannelKey = + '0000000000000000000000000000000000000000000000000000000000000000'; + for (final contact in storedContacts) { + // Skip any contacts with all-zeros public key (shouldn't happen, but safety check) + if (contact.publicKeyHex == publicChannelKey) { + continue; + } + _contacts[contact.publicKeyHex] = contact; + } + + _isInitialized = true; + _savedContactGroups + ..clear() + ..addAll(storedGroups); + _restorePendingAdverts( + storedPendingAdverts, + devicePublicKey: devicePublicKey, + ); + debugPrint( + '✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', + ); + + // Ensure public channel exists after loading + _ensurePublicChannelExists(); + + notifyListeners(); + } catch (e) { + debugPrint('❌ [ContactsProvider] Error in early initialization: $e'); + _isInitialized = true; // Mark as initialized even on error + _ensurePublicChannelExists(); + } + } + + /// Initialize and load persisted contacts + /// [devicePublicKey] - device's own public key to exclude from loaded contacts + Future initialize({Uint8List? devicePublicKey}) async { + _setSelfDevicePublicKey(devicePublicKey); + if (_isInitialized) { + // If already initialized (from early load), just filter out self-contact + if (devicePublicKey != null) { + _removeSelfContact(devicePublicKey); + } + return; + } + + await _loadFromStorage(force: true, devicePublicKey: devicePublicKey); + if (devicePublicKey != null) { + _removeSelfContact(devicePublicKey); + } + } + + /// 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 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), + ), + ); + } + + /// Finish a device contact sync started with [prepareForDeviceContactSync]. + /// + /// The device contact list is authoritative: when [deviceListComplete] is + /// true, any contact still left in the retained snapshot (i.e. not sent by + /// the device during this sync) is deleted locally. Channel pseudo-contacts + /// are excluded — they mirror device channel slots and are reconciled by the + /// channel sync instead. When the sync was partial (timeout/disconnect), + /// nothing is deleted so a flaky link can't wipe valid contacts. + void finalizeDeviceContactSync({required bool deviceListComplete}) { + if (!deviceListComplete) { + if (_retainedContactsForSync.isNotEmpty) { + debugPrint( + 'ℹ️ [ContactsProvider] Partial contact sync - keeping ' + '${_retainedContactsForSync.length} unconfirmed contact(s)', + ); + } + _retainedContactsForSync.clear(); + return; + } + + final staleKeys = _retainedContactsForSync.keys + .where((key) => _contacts[key]?.type != ContactType.channel) + .where(_contacts.containsKey) + .toList(); + for (final key in staleKeys) { + final removed = _contacts.remove(key); + debugPrint( + '🗑️ [ContactsProvider] Removing contact no longer on device: ' + '${removed?.advName} (${key.substring(0, 8)}...)', + ); + } + _retainedContactsForSync.clear(); + + if (staleKeys.isNotEmpty) { + _persistContacts(); + notifyListeners(); + } + } + + /// Remove self-contact from loaded contacts (called after BLE connection established) + void _removeSelfContact(Uint8List devicePublicKey) { + final selfKeyHex = devicePublicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + if (_contacts.containsKey(selfKeyHex)) { + final selfContact = _contacts[selfKeyHex]!; + debugPrint( + '🗑️ [ContactsProvider] Removing self-contact: ${selfContact.advName}', + ); + _contacts.remove(selfKeyHex); + _persistContacts(); + notifyListeners(); + } + } + + /// Ensure public channel always exists in the list + void _ensurePublicChannelExists() { + // Public channel has all-zeros public key (32 bytes = 64 hex chars) + const publicChannelKey = + '0000000000000000000000000000000000000000000000000000000000000000'; + if (!_contacts.containsKey(publicChannelKey)) { + // Create a pseudo-contact for the public channel (ephemeral broadcast) + _contacts[publicChannelKey] = Contact( + publicKey: Uint8List.fromList( + List.filled(32, 0), + ), // Zero key for public + type: ContactType.channel, // Channel type (not room!) + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Public Channel', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + } + + /// Persist contacts to storage (async, non-blocking, coalescing). + Future _persistContacts() async { + _persistRequested = true; + if (_isPersisting) return; + _isPersisting = true; + try { + while (_persistRequested) { + _persistRequested = false; + await _storageService.saveContacts( + _contactsForStorage(), + namespace: _storageNamespace, + ); + } + } catch (e) { + debugPrint('❌ [ContactsProvider] Error persisting contacts: $e'); + } finally { + _isPersisting = false; + } + } + + Future _persistPendingAdverts() async { + _persistPendingAdvertsRequested = true; + if (_isPersistingPendingAdverts) return; + _isPersistingPendingAdverts = true; + try { + while (_persistPendingAdvertsRequested) { + _persistPendingAdvertsRequested = false; + await _storageService.savePendingAdverts( + _pendingAdverts.values.map(_pendingAdvertToJson).toList(), + namespace: _storageNamespace, + ); + } + } catch (e) { + debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e'); + } finally { + _isPersistingPendingAdverts = false; + } + } + + List get contacts => _contacts.values.toList(); + ContactTelemetry? get selfTelemetry => _selfTelemetry; + List get favouriteContacts => + _contacts.values.where((c) => c.isFavourite).toList(); + List get favouriteChatContacts => + chatContacts.where((c) => c.isFavourite).toList(); + List get favouriteContactsWithLocation => + favouriteContacts.where((c) => effectiveLocationFor(c) != null).toList(); + List get savedContactGroups => + List.from(_savedContactGroups) + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + List get pendingAdverts => + _pendingAdverts.values.toList() + ..sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); + + PendingAdvert? pendingAdvertByKey(Uint8List publicKey) { + final keyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + return _pendingAdverts[keyHex]; + } + + bool shouldEnrichPendingAdvert(Uint8List publicKey) { + final advert = pendingAdvertByKey(publicKey); + if (advert == null) { + return false; + } + + final hasName = advert.advName?.trim().isNotEmpty ?? false; + final hasType = advert.typeValue != null && advert.typeValue != 0; + final hasLocation = + advert.advLat != null && + advert.advLon != null && + (advert.advLat != 0 || advert.advLon != 0); + return !(hasName && hasType && hasLocation); + } + + List savedGroupsForSection(String sectionKey) { + return savedContactGroups + .where((group) => group.sectionKey == sectionKey) + .toList(); + } + + bool hasSavedGroupForFilter(String sectionKey, String query) { + final normalizedQuery = _normalizeGroupQuery(query); + if (normalizedQuery.isEmpty) { + return false; + } + + return _savedContactGroups.any( + (group) => + group.sectionKey == sectionKey && + _normalizeGroupQuery(group.query) == normalizedQuery, + ); + } + + Future addSavedGroupForFilter( + String sectionKey, + String query, { + String? label, + List? matchPrefixes, + bool isAutoGroup = false, + }) async { + final normalizedQuery = _normalizeGroupQuery(query); + if (normalizedQuery.isEmpty || + hasSavedGroupForFilter(sectionKey, normalizedQuery)) { + return; + } + + _savedContactGroups.add( + SavedContactGroup( + id: '${sectionKey}_${DateTime.now().microsecondsSinceEpoch}', + sectionKey: sectionKey, + label: (label ?? query).trim(), + query: query.trim(), + createdAt: DateTime.now(), + matchPrefixes: matchPrefixes, + isAutoGroup: isAutoGroup, + ), + ); + + await _persistSavedGroups(); + notifyListeners(); + } + + Future removeSavedGroupById(String id) async { + final beforeCount = _savedContactGroups.length; + _savedContactGroups.removeWhere((group) => group.id == id); + if (_savedContactGroups.length == beforeCount) { + return; + } + + await _persistSavedGroups(); + notifyListeners(); + } + + Future removeSavedGroupForFilter( + String sectionKey, + String query, + ) async { + final normalizedQuery = _normalizeGroupQuery(query); + final beforeCount = _savedContactGroups.length; + _savedContactGroups.removeWhere( + (group) => + group.sectionKey == sectionKey && + _normalizeGroupQuery(group.query) == normalizedQuery, + ); + if (_savedContactGroups.length == beforeCount) { + return; + } + + await _persistSavedGroups(); + notifyListeners(); + } + + Future replaceAutoGroupsForSection( + String sectionKey, + List groups, + ) async { + _savedContactGroups.removeWhere( + (group) => group.sectionKey == sectionKey && group.isAutoGroup, + ); + _savedContactGroups.addAll(groups); + await _persistSavedGroups(); + notifyListeners(); + } + + Future _persistSavedGroups() async { + try { + await _storageService.saveContactGroups( + _savedContactGroups, + namespace: _storageNamespace, + ); + } catch (e) { + debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e'); + } + } + + String _normalizeGroupQuery(String query) => query.trim().toLowerCase(); + + List get chatContacts => + contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen); + + List get repeaters => + contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen); + + List get sensorContacts => + contacts.where((c) => c.isSensor).toList()..sort(_sortByLastSeen); + + List get rooms => + contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen); + + List get channels { + // Always ensure public channel exists when getting channels + _ensurePublicChannelExists(); + return contacts.where((c) => c.isChannel).toList()..sort(_sortByLastSeen); + } + + /// Get both rooms and channels (destinations for SAR markers) + List get roomsAndChannels { + _ensurePublicChannelExists(); + return contacts.where((c) => c.isRoom || c.isChannel).toList() + ..sort(_sortByLastSeen); + } + + /// All estimated locations (for passing to map marker service). + Map get estimatedLocations => + Map.unmodifiable(_estimatedLocations); + + /// Get estimated location for a contact (RSSI-based, near last-hop repeater). + LatLng? estimatedLocationFor(String publicKeyHex) => + _estimatedLocations[publicKeyHex]; + + /// Set an estimated location for a contact that has no GPS. + void setEstimatedLocation(String publicKeyHex, LatLng location) { + _estimatedLocations[publicKeyHex] = location; + notifyListeners(); + } + + /// Record an RSSI observation and re-trilaterate the contact's position. + /// + /// Keeps up to 5 observations per unique repeater (latest wins). + /// With multiple repeaters, trilateration produces a better estimate. + void addRssiObservation({ + required String contactPublicKeyHex, + required List contactPublicKey, + required RssiObservation observation, + }) { + final observations = _rssiObservations.putIfAbsent( + contactPublicKeyHex, + () => [], + ); + + // 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.add(observation); + + // Keep at most 8 observations (most recent per repeater) + if (observations.length > 8) { + observations.sort((a, b) => b.observedAt.compareTo(a.observedAt)); + observations.removeRange(8, observations.length); + } + + final estimated = RssiLocationEstimator.trilaterate( + observations: observations, + contactPublicKey: contactPublicKey, + ); + if (estimated != null) { + _estimatedLocations[contactPublicKeyHex] = estimated; + notifyListeners(); + } + } + + /// Effective location: own GPS/advert > estimated from RSSI. + LatLng? effectiveLocationFor(Contact contact) { + return contact.displayLocation ?? _estimatedLocations[contact.publicKeyHex]; + } + + /// Get contacts with location (for map display) + /// Includes contacts with estimated locations from RSSI. + List get contactsWithLocation => + contacts.where((c) => effectiveLocationFor(c) != null).toList(); + + /// Get chat contacts with location (team members on map) + List get chatContactsWithLocation => + chatContacts.where((c) => effectiveLocationFor(c) != null).toList(); + + MessageContactLocation? buildMessageContactLocationSnapshot( + Contact contact, { + DateTime? capturedAt, + }) { + final snapshotTime = capturedAt ?? DateTime.now(); + final telemetryGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation); + final telemetryTimestamp = contact.telemetry?.timestamp; + + AdvertLocation? advertLocation; + for (final point in contact.advertHistory) { + if (!point.timestamp.isAfter(snapshotTime)) { + advertLocation = point; + break; + } + } + advertLocation ??= contact.advertHistory.isNotEmpty + ? contact.advertHistory.first + : null; + + if (telemetryGps != null) { + final shouldUseTelemetry = + telemetryTimestamp == null || + advertLocation == null || + !telemetryTimestamp.isBefore(advertLocation.timestamp); + if (shouldUseTelemetry) { + return MessageContactLocation( + location: telemetryGps, + source: 'telemetry', + capturedAt: snapshotTime, + sourceTimestamp: telemetryTimestamp, + ); + } + } + + if (advertLocation != null) { + return MessageContactLocation( + location: advertLocation.location, + source: 'advert', + capturedAt: snapshotTime, + sourceTimestamp: advertLocation.timestamp, + ); + } + + return null; + } + + /// Sort contacts by last seen (most recent first) + int _sortByLastSeen(Contact a, Contact b) { + return b.lastSeenTime.compareTo(a.lastSeenTime); + } + + /// Add or update a contact + /// Excludes contacts that match the device's own public key + void addOrUpdateContact(Contact contact, {Uint8List? devicePublicKey}) { + debugPrint( + '📝 [ContactsProvider] addOrUpdateContact called: ${contact.advName} (type: ${contact.type.displayName}, key: ${contact.publicKeyHex.substring(0, 8)}...)', + ); + + // Don't add contacts that match our device's public key + if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) { + debugPrint( + 'ℹ️ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}', + ); + return; + } + + // Check if this is a new contact + final existingContact = + _contacts[contact.publicKeyHex] ?? + _retainedContactsForSync[contact.publicKeyHex]; + final isNewContact = existingContact == null; + debugPrint( + ' isNew: $isNewContact, total contacts before: ${_contacts.length}', + ); + + final updatedContact = _mergeIncomingContact( + incomingContact: contact, + existingContact: existingContact, + ); + + _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 && + contact.type != ContactType.sensor) { + _pendingAdverts.remove(contact.publicKeyHex); + } + debugPrint( + ' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}', + ); + _persistContacts(); + _persistPendingAdverts(); + notifyListeners(); + debugPrint(' 🔔 notifyListeners() called'); + } + + /// Add multiple contacts + /// Excludes contacts that match the device's own public key + void addContacts(List contacts, {Uint8List? devicePublicKey}) { + int excluded = 0; + for (final contact in contacts) { + // Don't add contacts that match our device's public key + if (devicePublicKey != null && + contact.publicKey.matches(devicePublicKey)) { + debugPrint( + 'ℹ️ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}', + ); + excluded++; + continue; + } + 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); + } + } + if (excluded > 0) { + debugPrint( + 'ℹ️ [ContactsProvider] Excluded $excluded contact(s) matching device public key', + ); + } + _persistContacts(); + _persistPendingAdverts(); + notifyListeners(); + } + + Contact _mergeIncomingContact({ + required Contact incomingContact, + Contact? existingContact, + }) { + final retainedRoute = _retainedRouteForContact( + keyHex: incomingContact.publicKeyHex, + incomingContact: incomingContact, + existingContact: existingContact, + ); + final mergedTelemetry = _mergeTelemetryForContact( + existingTelemetry: existingContact?.telemetry, + incomingTelemetry: incomingContact.telemetry, + ); + final inferredFallbackLocation = _inferFirstHopFallbackLocation( + incomingContact: incomingContact, + existingContact: existingContact, + retainedRoute: retainedRoute, + mergedTelemetry: mergedTelemetry, + ); + final inferredFallbackAdvLat = inferredFallbackLocation != null + ? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.latitude) + : null; + final inferredFallbackAdvLon = inferredFallbackLocation != null + ? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.longitude) + : null; + + if (existingContact == null) { + var newContact = incomingContact.copyWith( + isNew: true, + telemetry: mergedTelemetry, + outPathLen: + retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen, + outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath, + advLat: incomingContact.advertLocation != null + ? incomingContact.advLat + : inferredFallbackAdvLat ?? incomingContact.advLat, + advLon: incomingContact.advertLocation != null + ? incomingContact.advLon + : inferredFallbackAdvLon ?? incomingContact.advLon, + ); + if (incomingContact.advertLocation != null) { + final timestamp = DateTime.fromMillisecondsSinceEpoch( + incomingContact.lastAdvert * 1000, + ); + newContact = newContact.addAdvertLocation( + incomingContact.advertLocation!, + timestamp, + ); + } + return newContact; + } + + final incomingAdvertLocation = incomingContact.advertLocation; + final existingAdvertLocation = existingContact.advertLocation; + + var updatedContact = incomingContact.copyWith( + isNew: false, + nameOverride: existingContact.nameOverride, + advertHistory: existingContact.advertHistory, + telemetry: mergedTelemetry, + outPathLen: + retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen, + outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath, + advLat: incomingAdvertLocation != null + ? incomingContact.advLat + : existingAdvertLocation != null + ? existingContact.advLat + : inferredFallbackAdvLat ?? incomingContact.advLat, + advLon: incomingAdvertLocation != null + ? incomingContact.advLon + : existingAdvertLocation != null + ? existingContact.advLon + : inferredFallbackAdvLon ?? incomingContact.advLon, + ); + + if (incomingAdvertLocation != null) { + final timestamp = DateTime.fromMillisecondsSinceEpoch( + incomingContact.lastAdvert * 1000, + ); + updatedContact = updatedContact.addAdvertLocation( + incomingAdvertLocation, + timestamp, + ); + } + + return updatedContact; + } + + _RetainedRoute? _retainedRouteForContact({ + required String keyHex, + required Contact incomingContact, + required Contact? existingContact, + }) { + if (incomingContact.routeHasPath) { + return null; + } + + final pendingAdvert = _pendingAdverts[keyHex]; + final pendingPathBytes = pendingAdvert?.paddedPathBytes; + final pendingPathLen = pendingAdvert?.signedEncodedPathLen; + if (pendingPathLen != null && + pendingPathBytes != null && + pendingPathBytes.isNotEmpty) { + return _RetainedRoute( + signedEncodedPathLen: pendingPathLen, + paddedPathBytes: Uint8List.fromList(pendingPathBytes), + ); + } + + if (existingContact != null && existingContact.routeHasPath) { + return _RetainedRoute( + signedEncodedPathLen: existingContact.outPathLen, + paddedPathBytes: Uint8List.fromList(existingContact.outPath), + ); + } + + return null; + } + + LatLng? _inferFirstHopFallbackLocation({ + required Contact incomingContact, + required Contact? existingContact, + required _RetainedRoute? retainedRoute, + required ContactTelemetry? mergedTelemetry, + }) { + if (_getValidGpsOrNull(mergedTelemetry?.gpsLocation) != null) { + return null; + } + if (incomingContact.advertLocation != null || + existingContact?.advertLocation != null) { + return null; + } + + final routeBytes = + retainedRoute?.paddedPathBytes ?? + (incomingContact.routeHasPath + ? incomingContact.routePathBytes + : existingContact?.routeHasPath == true + ? existingContact!.routePathBytes + : null); + final routeHashSize = retainedRoute != null + ? ((ContactRouteCodec.toUnsignedDescriptor( + retainedRoute.signedEncodedPathLen, + ) >> + 6) + + 1) + : incomingContact.routeHasPath + ? incomingContact.routeHashSize + : existingContact?.routeHasPath == true + ? existingContact!.routeHashSize + : 0; + if (routeBytes == null || + routeHashSize <= 0 || + routeBytes.length < routeHashSize) { + return null; + } + + final lastHopHex = routeBytes + .sublist(routeBytes.length - routeHashSize) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + final repeaterCandidates = _contacts.values.where((candidate) { + if (!candidate.isRepeater || + candidate.publicKeyHex == incomingContact.publicKeyHex) { + return false; + } + final location = candidate.displayLocation; + return location != null && candidate.publicKeyHex.startsWith(lastHopHex); + }).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert)); + if (repeaterCandidates.isEmpty) { + return null; + } + + final repeaterLocation = repeaterCandidates.first.displayLocation; + if (repeaterLocation == null) { + return null; + } + + final bearingDegrees = _stableFallbackBearingDegrees(incomingContact); + return _offsetFromLocation( + repeaterLocation, + distanceMeters: _firstHopFallbackOffsetMeters, + bearingDegrees: bearingDegrees, + ); + } + + double _stableFallbackBearingDegrees(Contact contact) { + if (contact.publicKey.length < 2) { + return 90.0; + } + final seed = (contact.publicKey[0] << 8) | contact.publicKey[1]; + return (seed % 360).toDouble(); + } + + LatLng _offsetFromLocation( + LatLng origin, { + required double distanceMeters, + required double bearingDegrees, + }) { + const earthRadiusMeters = 6371000.0; + final angularDistance = distanceMeters / earthRadiusMeters; + final bearingRadians = bearingDegrees * 3.1415926535897932 / 180.0; + final lat1 = origin.latitude * 3.1415926535897932 / 180.0; + final lon1 = origin.longitude * 3.1415926535897932 / 180.0; + + final sinLat1 = sin(lat1); + final cosLat1 = cos(lat1); + final sinAngularDistance = sin(angularDistance); + final cosAngularDistance = cos(angularDistance); + + final lat2 = asin( + sinLat1 * cosAngularDistance + + cosLat1 * sinAngularDistance * cos(bearingRadians), + ); + final lon2 = + lon1 + + atan2( + sin(bearingRadians) * sinAngularDistance * cosLat1, + cosAngularDistance - sinLat1 * sin(lat2), + ); + + return LatLng( + lat2 * 180.0 / 3.1415926535897932, + ((lon2 * 180.0 / 3.1415926535897932 + 540.0) % 360.0) - 180.0, + ); + } + + /// Update contact telemetry + // Dedup: track last telemetry data hash per contact to avoid processing + // the same payload twice (0x8B + 0x8C can both fire for the same data). + final Map _lastTelemetryHash = {}; + + void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { + debugPrint('📊 [ContactsProvider] updateTelemetry() called'); + debugPrint( + ' Public key prefix (hex): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}', + ); + debugPrint(' LPP data size: ${lppData.length} bytes'); + + // Deduplicate: same data for same contact within 2 seconds = skip + final prefixHex = publicKeyPrefix + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final dataHash = lppData.fold(0, (h, b) => h * 31 + b); + final now = DateTime.now(); + final last = _lastTelemetryHash[prefixHex]; + if (last != null && + last.$1 == dataHash && + now.difference(last.$2).inSeconds < 2) { + debugPrint(' ⏭️ Duplicate telemetry payload, skipping'); + return; + } + _lastTelemetryHash[prefixHex] = (dataHash, now); + + // Find contact by public key prefix + final contact = _findContactByPrefix(publicKeyPrefix); + final isSelfTelemetry = + contact == null && _matchesSelfPrefix(publicKeyPrefix); + if (contact == null && !isSelfTelemetry) { + debugPrint(' ❌ Contact not found for this prefix'); + return; + } + + 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 = _withRawTelemetryHex( + CayenneLppParser.parse(lppData), + lppData, + ); + debugPrint(' ✅ Parsed new telemetry'); + debugPrint(' New telemetry timestamp: ${telemetry.timestamp}'); + + final incomingGps = telemetry.gpsLocation; + if (_isInvalidTelemetryGps(incomingGps)) { + // Always sanitize invalid placeholder GPS to null first. + telemetry = ContactTelemetry( + gpsLocation: null, + batteryPercentage: telemetry.batteryPercentage, + batteryMilliVolts: telemetry.batteryMilliVolts, + temperature: telemetry.temperature, + timestamp: telemetry.timestamp, + humidity: telemetry.humidity, + pressure: telemetry.pressure, + extraSensorData: telemetry.extraSensorData, + ); + } + + final previousTelemetry = isSelfTelemetry + ? _selfTelemetry + : contact!.telemetry; + + final mergedTelemetry = _mergeTelemetryForContact( + existingTelemetry: previousTelemetry, + incomingTelemetry: telemetry, + ); + if (mergedTelemetry != null) { + if (_shouldRetainLastValidGps( + previousTelemetry, + telemetry.gpsLocation, + )) { + debugPrint( + ' ⚠️ Incoming telemetry GPS is invalid/missing; keeping last known contact location as fallback: $incomingGps', + ); + } + telemetry = ContactTelemetry( + gpsLocation: telemetry.gpsLocation, + batteryPercentage: mergedTelemetry.batteryPercentage, + batteryMilliVolts: mergedTelemetry.batteryMilliVolts, + temperature: mergedTelemetry.temperature, + timestamp: mergedTelemetry.timestamp, + humidity: mergedTelemetry.humidity, + pressure: mergedTelemetry.pressure, + 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: ${resolvedContact.lastAdvert}'); + debugPrint(' New lastAdvert: $currentTimestamp'); + + final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation); + final updatedContact = resolvedContact.copyWith( + telemetry: telemetry, + lastAdvert: currentTimestamp, // Update last seen time + advLat: persistedGps != null + ? _coordinateToAdvertMicrodegrees(persistedGps.latitude) + : resolvedContact.advLat, + advLon: persistedGps != null + ? _coordinateToAdvertMicrodegrees(persistedGps.longitude) + : resolvedContact.advLon, + ); + _contacts[resolvedContact.publicKeyHex] = updatedContact; + debugPrint(' ✅ Updated contact in map (with new lastAdvert)'); + + _persistContacts(); + debugPrint(' ✅ Persisted contacts to storage'); + + notifyListeners(); + debugPrint(' ✅ Notified listeners - UI should update'); + } catch (e) { + debugPrint(' ❌ Failed to parse telemetry: $e'); + debugPrint('Failed to parse telemetry: $e'); + } + } + + void updateFastGps(Uint8List publicKeyPrefix, FastGpsPacket packet) { + final contact = _findContactByPrefix(publicKeyPrefix); + if (contact == null) { + debugPrint( + '⚠️ [ContactsProvider] Fast GPS sender not found: ${packet.senderKey6}', + ); + return; + } + + // The fast-GPS beacon carries no timestamp — stamp our own receive time. + final receivedAt = DateTime.now(); + final receivedAtSeconds = receivedAt.millisecondsSinceEpoch ~/ 1000; + + final updatedTelemetry = _mergeTelemetryForContact( + existingTelemetry: contact.telemetry, + incomingTelemetry: ContactTelemetry( + gpsLocation: LatLng(packet.latitude, packet.longitude), + batteryPercentage: null, + batteryMilliVolts: null, + temperature: null, + timestamp: receivedAt, + humidity: null, + pressure: null, + extraSensorData: null, + ), + ); + + debugPrint( + '📥 [ContactsProvider] Fast GPS received ' + 'sender=${packet.senderKey6} ' + 'contact=${contact.displayName} ' + 'contactKey=${contact.publicKeyHex} ' + 'lat=${packet.latitude} ' + 'lon=${packet.longitude} ' + 'speed=${packet.speedKmh}km/h', + ); + + final updatedContact = contact.copyWith( + telemetry: updatedTelemetry, + lastAdvert: receivedAtSeconds, + lastMod: receivedAtSeconds, + 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(); + } + + bool _isInvalidTelemetryGps(LatLng? location) { + if (location == null) return false; + final lat = location.latitude; + final lon = location.longitude; + + if (!lat.isFinite || !lon.isFinite) return true; + + // Many devices report "0000" placeholder GPS as (0.0, 0.0). + const epsilon = 1e-7; + return lat.abs() < epsilon && lon.abs() < epsilon; + } + + LatLng? _getValidGpsOrNull(LatLng? location) { + if (location == null || _isInvalidTelemetryGps(location)) { + return null; + } + return location; + } + + bool _shouldRetainLastValidGps( + ContactTelemetry? existingTelemetry, + LatLng? incomingGps, + ) { + final hasPreviousValidGps = + _getValidGpsOrNull(existingTelemetry?.gpsLocation) != null; + if (!hasPreviousValidGps) { + return false; + } + + return incomingGps == null; + } + + ContactTelemetry? _mergeTelemetryForContact({ + ContactTelemetry? existingTelemetry, + ContactTelemetry? incomingTelemetry, + }) { + if (incomingTelemetry == null) { + return existingTelemetry; + } + + // Telemetry packets and contact refreshes are often sparse. Preserve the + // last known reading for any field that is omitted in the incoming update. + final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation); + final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation); + final mergedExtraSensorData = _mergeExtraSensorData( + existingTelemetry?.extraSensorData, + incomingTelemetry.extraSensorData, + ); + + return ContactTelemetry( + gpsLocation: incomingGps ?? previousGps, + batteryPercentage: + incomingTelemetry.batteryPercentage ?? + existingTelemetry?.batteryPercentage, + batteryMilliVolts: + incomingTelemetry.batteryMilliVolts ?? + existingTelemetry?.batteryMilliVolts, + temperature: + incomingTelemetry.temperature ?? existingTelemetry?.temperature, + timestamp: incomingTelemetry.timestamp, + humidity: incomingTelemetry.humidity ?? existingTelemetry?.humidity, + pressure: incomingTelemetry.pressure ?? existingTelemetry?.pressure, + extraSensorData: mergedExtraSensorData.isEmpty + ? null + : mergedExtraSensorData, + ); + } + + ContactTelemetry _withRawTelemetryHex( + ContactTelemetry telemetry, + Uint8List lppData, + ) { + final extraSensorData = {...?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 _mergeExtraSensorData( + Map? existingExtraSensorData, + Map? incomingExtraSensorData, + ) { + if (incomingExtraSensorData == null || incomingExtraSensorData.isEmpty) { + return {...?existingExtraSensorData}; + } + + final isFullTelemetryRefresh = incomingExtraSensorData.containsKey( + _rawTelemetryHexKey, + ); + final merged = { + if (!isFullTelemetryRefresh) ...?existingExtraSensorData, + }; + + final incomingMetricFamilies = incomingExtraSensorData.keys + .map(_telemetryMetricFamilyForKey) + .whereType() + .toSet(); + if (incomingMetricFamilies.isNotEmpty) { + merged.removeWhere((key, _) { + final family = _telemetryMetricFamilyForKey(key); + return family != null && incomingMetricFamilies.contains(family); + }); + } + + merged.addAll(incomingExtraSensorData); + return merged; + } + + String? _telemetryMetricFamilyForKey(String key) { + const sourcePrefix = '__source_channel:'; + if (key.startsWith(sourcePrefix)) { + return key.substring(sourcePrefix.length); + } + + final separatorIndex = key.lastIndexOf('_'); + if (separatorIndex <= 0 || separatorIndex == key.length - 1) { + return key; + } + + final suffix = key.substring(separatorIndex + 1); + if (int.tryParse(suffix) == null) { + return key; + } + + return key.substring(0, separatorIndex); + } + + int _coordinateToAdvertMicrodegrees(double coordinate) { + return (coordinate * 1e6).round(); + } + + /// Find contact by public key prefix (6 bytes) + Contact? _findContactByPrefix(Uint8List prefix) { + if (prefix.isEmpty) return null; + + final takeLen = prefix.length < 6 ? prefix.length : 6; + final prefixHex = prefix + .sublist(0, takeLen) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + + for (final contact in _contacts.values) { + if (contact.publicKeyHex.startsWith(prefixHex)) { + return contact; + } + } + return null; + } + + /// Find contact by first 6-byte public key prefix. + Contact? findContactByPrefix(Uint8List prefix) { + return _findContactByPrefix(prefix); + } + + /// Find contact by 12-hex-char public key prefix. + Contact? findContactByPrefixHex(String prefixHex) { + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(prefixHex)) return null; + final bytes = Uint8List(6); + for (var i = 0; i < 6; i++) { + final start = i * 2; + bytes[i] = int.parse(prefixHex.substring(start, start + 2), radix: 16); + } + return _findContactByPrefix(bytes); + } + + /// Find contact by public key + Contact? findContactByKey(Uint8List publicKey) { + final keyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + 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) { + final contact = findContactByKey(publicKey); + if (contact == null || !contact.routeHasPath) { + return; + } + + _contacts[contact.publicKeyHex] = contact.copyWith( + outPathLen: -1, + outPath: Uint8List(0), + ); + _persistContacts(); + notifyListeners(); + } + + void setContactRouteLocal( + Uint8List publicKey, { + required int signedEncodedPathLen, + required Uint8List paddedPathBytes, + LatLng? inferredFallbackLocation, + }) { + final contact = findContactByKey(publicKey); + if (contact == null) { + return; + } + + var updatedContact = contact.copyWith( + outPathLen: signedEncodedPathLen, + outPath: Uint8List.fromList(paddedPathBytes), + ); + if (inferredFallbackLocation != null) { + updatedContact = updatedContact + .copyWith( + advLat: _coordinateToAdvertMicrodegrees( + inferredFallbackLocation.latitude, + ), + advLon: _coordinateToAdvertMicrodegrees( + inferredFallbackLocation.longitude, + ), + ) + .addAdvertLocation(inferredFallbackLocation, DateTime.now()); + } + _contacts[contact.publicKeyHex] = updatedContact; + _persistContacts(); + notifyListeners(); + } + + void retainReceivedRoute( + Uint8List publicKey, { + required int signedEncodedPathLen, + required Uint8List paddedPathBytes, + Uint8List? devicePublicKey, + }) { + if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { + return; + } + + final keyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final contact = _contacts[keyHex]; + if (contact != null) { + _contacts[keyHex] = contact.copyWith( + outPathLen: signedEncodedPathLen, + outPath: Uint8List.fromList(paddedPathBytes), + ); + _persistContacts(); + notifyListeners(); + return; + } + + final now = DateTime.now(); + final existing = _pendingAdverts[keyHex]; + _pendingAdverts[keyHex] = + (existing ?? + PendingAdvert( + publicKey: Uint8List.fromList(publicKey), + receivedAt: now, + )) + .copyWith( + receivedAt: now, + signedEncodedPathLen: signedEncodedPathLen, + paddedPathBytes: Uint8List.fromList(paddedPathBytes), + ); + notifyListeners(); + } + + void resetContactRouteLocal(Uint8List publicKey) { + final contact = findContactByKey(publicKey); + if (contact == null) { + return; + } + + _contacts[contact.publicKeyHex] = contact.copyWith( + outPathLen: -1, + outPath: Uint8List(0), + ); + _persistContacts(); + notifyListeners(); + } + + void setContactNameOverride(String publicKeyHex, String? overrideName) { + final contact = _contacts[publicKeyHex]; + if (contact == null) { + return; + } + + final normalizedOverride = overrideName?.trim(); + final nextOverride = + (normalizedOverride == null || normalizedOverride.isEmpty) + ? null + : normalizedOverride; + if (contact.nameOverride == nextOverride) { + return; + } + + _contacts[publicKeyHex] = contact.copyWith(nameOverride: nextOverride); + _persistContacts(); + notifyListeners(); + } + + /// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80). + /// Excludes only self key; known contacts still keep a discovery entry. + bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { + if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { + return false; + } + + final keyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final existing = _pendingAdverts[keyHex]; + final now = DateTime.now(); + if (existing != null) { + _pendingAdverts[keyHex] = existing.copyWith(receivedAt: now); + _persistPendingAdverts(); + notifyListeners(); + return false; + } else { + _pendingAdverts[keyHex] = PendingAdvert( + publicKey: Uint8List.fromList(publicKey), + receivedAt: now, + ); + _persistPendingAdverts(); + notifyListeners(); + return true; + } + } + + bool addOrUpdatePendingAdvertContact( + Contact contact, { + Uint8List? devicePublicKey, + }) { + if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) { + return false; + } + + final keyHex = contact.publicKeyHex; + final route = ContactRouteCodec.fromContact(contact); + final existing = _pendingAdverts[keyHex]; + final now = DateTime.now(); + final updated = + (existing ?? + PendingAdvert( + publicKey: Uint8List.fromList(contact.publicKey), + receivedAt: now, + )) + .copyWith( + receivedAt: now, + advName: contact.advName.trim().isEmpty ? null : contact.advName, + typeValue: contact.type.value, + flags: contact.flags, + lastAdvert: contact.lastAdvert, + advLat: contact.advLat, + advLon: contact.advLon, + signedEncodedPathLen: + route?.signedEncodedPathLen ?? existing?.signedEncodedPathLen, + paddedPathBytes: route?.paddedPathBytes == null + ? existing?.paddedPathBytes + : Uint8List.fromList(route!.paddedPathBytes), + ); + + _pendingAdverts[keyHex] = updated; + _persistPendingAdverts(); + notifyListeners(); + return existing == null; + } + + bool addOrUpdatePendingAdvertMetadata({ + required Uint8List publicKey, + required int typeValue, + Uint8List? devicePublicKey, + int? flags, + String? advName, + int? lastAdvert, + int? advLat, + int? advLon, + int? signedEncodedPathLen, + Uint8List? paddedPathBytes, + int? rxRssiDbm, + int? rxSnrRaw, + DateTime? receivedAt, + }) { + if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { + return false; + } + + final keyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final existing = _pendingAdverts[keyHex]; + final nextReceivedAt = receivedAt ?? DateTime.now(); + _pendingAdverts[keyHex] = + (existing ?? + PendingAdvert( + publicKey: Uint8List.fromList(publicKey), + receivedAt: nextReceivedAt, + )) + .copyWith( + receivedAt: nextReceivedAt, + typeValue: typeValue, + advName: advName?.trim().isNotEmpty == true + ? advName!.trim() + : existing?.advName, + flags: flags ?? existing?.flags, + lastAdvert: lastAdvert ?? existing?.lastAdvert, + advLat: advLat ?? existing?.advLat, + advLon: advLon ?? existing?.advLon, + signedEncodedPathLen: + signedEncodedPathLen ?? existing?.signedEncodedPathLen, + paddedPathBytes: paddedPathBytes == null + ? existing?.paddedPathBytes + : Uint8List.fromList(paddedPathBytes), + rxRssiDbm: rxRssiDbm ?? existing?.rxRssiDbm, + rxSnrRaw: rxSnrRaw ?? existing?.rxSnrRaw, + ); + + _persistPendingAdverts(); + notifyListeners(); + return existing == null; + } + + /// Find contact by name + Contact? findContactByName(String name) { + for (final contact in _contacts.values) { + if (contact.advName == name) return contact; + } + return null; + } + + /// Get contacts with low battery + List get lowBatteryContacts { + return contacts.where((c) { + final battery = c.displayBattery; + return battery != null && battery < 20.0; + }).toList(); + } + + /// Get recently seen contacts (within last 10 minutes) + List get recentlySeenContacts { + return contacts.where((c) => c.isRecentlySeen).toList(); + } + + /// Get count of new contacts (not yet viewed) + int get newContactsCount => + contacts.where((c) => c.isNew && !c.isChannel).length; + + /// Mark all contacts as viewed (not new) + void markAllAsViewed() { + bool hasChanges = false; + _contacts.forEach((key, contact) { + if (contact.isNew && !contact.isChannel) { + _contacts[key] = contact.copyWith(isNew: false); + hasChanges = true; + } + }); + if (hasChanges) { + _persistContacts(); + notifyListeners(); + } + } + + /// Mark a specific contact as viewed (not new) + void markAsViewed(String publicKeyHex) { + final contact = _contacts[publicKeyHex]; + if (contact != null && contact.isNew) { + _contacts[publicKeyHex] = contact.copyWith(isNew: false); + _persistContacts(); + notifyListeners(); + } + } + + /// Clear all contacts + void clearContacts() { + _contacts.clear(); + _pendingAdverts.clear(); + _retainedContactsForSync.clear(); + _ensurePublicChannelExists(); + _persistContacts(); + _persistPendingAdverts(); + notifyListeners(); + } + + /// Remove a contact + /// [onRemoveFromDevice] - Optional callback to remove contact from BLE device + Future removeContact( + String publicKeyHex, { + Future Function(Uint8List)? onRemoveFromDevice, + }) async { + // Get the contact before removing + final contact = _contacts[publicKeyHex]; + if (contact == null) return; + + // Remove from device first if callback provided + if (onRemoveFromDevice != null) { + await onRemoveFromDevice(contact.publicKey); + } + + // Then remove from local storage + _contacts.remove(publicKeyHex); + _pendingAdverts.remove(publicKeyHex); + _retainedContactsForSync.remove(publicKeyHex); + _persistContacts(); + _persistPendingAdverts(); + notifyListeners(); + } + + /// Get storage statistics + Future> getStorageStats() async { + return await _storageService.getStorageStats(namespace: _storageNamespace); + } + + Future clearPendingAdverts() async { + if (_pendingAdverts.isEmpty) { + return; + } + _pendingAdverts.clear(); + await _storageService.clearPendingAdverts(namespace: _storageNamespace); + notifyListeners(); + } + + void updatePendingAdvertStatusByPrefix( + Uint8List publicKeyPrefix, { + int? batteryMv, + int? queueLen, + int? lastRssi, + int? lastSnrRaw, + int? uptimeSecs, + }) { + final prefixHex = publicKeyPrefix + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final match = _pendingAdverts.entries.where( + (entry) => entry.key.startsWith(prefixHex), + ); + if (match.isEmpty) { + return; + } + final key = match.first.key; + final existing = _pendingAdverts[key]!; + _pendingAdverts[key] = existing.copyWith( + repeaterBatteryMv: batteryMv, + repeaterQueueLen: queueLen, + repeaterLastRssi: lastRssi, + repeaterLastSnrRaw: lastSnrRaw, + repeaterUptimeSecs: uptimeSecs, + ); + _persistPendingAdverts(); + notifyListeners(); + } + + List _contactsForStorage() { + // Don't persist the public channel pseudo-contact (all zeros key) + const publicChannelKey = + '0000000000000000000000000000000000000000000000000000000000000000'; + return _contacts.entries + .where((entry) => entry.key != publicChannelKey) + .map((entry) => entry.value) + .toList(); + } + + void _restorePendingAdverts( + List> storedPendingAdverts, { + Uint8List? devicePublicKey, + }) { + _pendingAdverts.clear(); + for (final json in storedPendingAdverts) { + final advert = _pendingAdvertFromJson(json); + if (advert == null) continue; + if (devicePublicKey != null && + advert.publicKey.matches(devicePublicKey)) { + continue; + } + if (_contacts.containsKey(advert.publicKeyHex)) { + continue; + } + _pendingAdverts[advert.publicKeyHex] = advert; + } + } + + void _resetInMemoryState() { + _contacts.clear(); + _savedContactGroups.clear(); + _pendingAdverts.clear(); + _estimatedLocations.clear(); + _rssiObservations.clear(); + _retainedContactsForSync.clear(); + _selfTelemetry = null; + } + + Map _pendingAdvertToJson(PendingAdvert advert) { + return { + 'publicKey': base64Encode(advert.publicKey), + 'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch, + 'advName': advert.advName, + 'typeValue': advert.typeValue, + 'flags': advert.flags, + 'lastAdvert': advert.lastAdvert, + 'advLat': advert.advLat, + 'advLon': advert.advLon, + 'signedEncodedPathLen': advert.signedEncodedPathLen, + 'paddedPathBytes': advert.paddedPathBytes == null + ? null + : base64Encode(advert.paddedPathBytes!), + 'rxRssiDbm': advert.rxRssiDbm, + 'rxSnrRaw': advert.rxSnrRaw, + 'repeaterBatteryMv': advert.repeaterBatteryMv, + 'repeaterQueueLen': advert.repeaterQueueLen, + 'repeaterLastRssi': advert.repeaterLastRssi, + 'repeaterLastSnrRaw': advert.repeaterLastSnrRaw, + 'repeaterUptimeSecs': advert.repeaterUptimeSecs, + }; + } + + PendingAdvert? _pendingAdvertFromJson(Map json) { + try { + return PendingAdvert( + publicKey: Uint8List.fromList( + base64Decode(json['publicKey'] as String), + ), + receivedAt: DateTime.fromMillisecondsSinceEpoch( + json['receivedAtMillis'] as int, + ), + advName: json['advName'] as String?, + typeValue: json['typeValue'] as int?, + flags: json['flags'] as int?, + lastAdvert: json['lastAdvert'] as int?, + advLat: json['advLat'] as int?, + advLon: json['advLon'] as int?, + signedEncodedPathLen: json['signedEncodedPathLen'] as int?, + paddedPathBytes: json['paddedPathBytes'] == null + ? null + : Uint8List.fromList( + base64Decode(json['paddedPathBytes'] as String), + ), + rxRssiDbm: json['rxRssiDbm'] as int?, + rxSnrRaw: json['rxSnrRaw'] as int?, + repeaterBatteryMv: json['repeaterBatteryMv'] as int?, + repeaterQueueLen: json['repeaterQueueLen'] as int?, + repeaterLastRssi: json['repeaterLastRssi'] as int?, + repeaterLastSnrRaw: json['repeaterLastSnrRaw'] as int?, + repeaterUptimeSecs: json['repeaterUptimeSecs'] as int?, + ); + } catch (e) { + debugPrint( + '❌ [ContactsProvider] Error parsing pending advert from JSON: $e', + ); + return null; + } + } + + /// Get contact count by type + Map get contactCounts { + return { + 'chat': chatContacts.length, + 'repeater': repeaters.length, + 'sensor': sensorContacts.length, + 'room': rooms.length, + 'total': contacts.length, + }; + } +} diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart new file mode 100644 index 0000000..300c5ce --- /dev/null +++ b/lib/providers/drawing_provider.dart @@ -0,0 +1,628 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/map_drawing.dart'; +import '../models/map_coordinate_space.dart'; +import '../services/profiles_feature_service.dart'; +import '../utils/drawing_message_parser.dart'; + +/// Drawing mode state +enum DrawingMode { none, line, rectangle, measure } + +/// Provider for managing map drawings +class DrawingProvider with ChangeNotifier { + static const String _storageKey = 'map_drawings'; + static const String _showReceivedDrawingsKey = 'map_show_received_drawings'; + static const String _showSarMarkersKey = 'map_show_sar_markers'; + + // Drawing state + DrawingMode _drawingMode = DrawingMode.none; + Color _selectedColor = DrawingColors.palette[0]; + bool _showReceivedDrawings = true; + bool _showSarMarkers = true; + + // Completed drawings + final List _drawings = []; + bool _isInitialized = false; + + // In-progress drawing + MapDrawing? _currentDrawing; + List _currentLinePoints = []; + LatLng? _rectangleStartPoint; + + // Distance measurement state + LatLng? _measurementPoint1; + LatLng? _measurementPoint2; + double? _measuredDistance; // in meters + MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo; + String? _activeMapId; + double? _activeMetersPerPixel; + + // Getters + DrawingMode get drawingMode => _drawingMode; + Color get selectedColor => _selectedColor; + bool get showReceivedDrawings => _showReceivedDrawings; + bool get showSarMarkers => _showSarMarkers; + MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace; + String? get activeMapId => _activeMapId; + double? get activeMetersPerPixel => _activeMetersPerPixel; + List get drawings { + // Filter out hidden drawings first + var visibleDrawings = _drawings.where((d) => !d.isHidden); + + // Then filter by received status if needed + if (!_showReceivedDrawings) { + visibleDrawings = visibleDrawings.where((d) => !d.isReceived); + } + + visibleDrawings = visibleDrawings.where((drawing) { + if (drawing.coordinateSpace != _activeCoordinateSpace) { + return false; + } + if (drawing.coordinateSpace == MapCoordinateSpace.customMap) { + return drawing.mapId != null && drawing.mapId == _activeMapId; + } + return true; + }); + + return List.unmodifiable(visibleDrawings.toList()); + } + + MapDrawing? get currentDrawing => _currentDrawing; + List get currentLinePoints => List.unmodifiable(_currentLinePoints); + LatLng? get rectangleStartPoint => _rectangleStartPoint; + bool get isDrawing => _drawingMode != DrawingMode.none; + bool get isInitialized => _isInitialized; + LatLng? get measurementPoint1 => _measurementPoint1; + LatLng? get measurementPoint2 => _measurementPoint2; + double? get measuredDistance => _measuredDistance; + + /// Initialize and load saved drawings + Future initialize() async { + await _loadPreferences(); + await _loadDrawings(); + _isInitialized = true; + } + + Future reloadProfileScopedState() async { + await _loadPreferences(); + await _loadDrawings(); + notifyListeners(); + } + + void setMapContext({ + required MapCoordinateSpace coordinateSpace, + String? mapId, + double? metersPerPixel, + }) { + final changed = + coordinateSpace != _activeCoordinateSpace || + mapId != _activeMapId || + metersPerPixel != _activeMetersPerPixel; + _activeCoordinateSpace = coordinateSpace; + _activeMapId = mapId; + _activeMetersPerPixel = metersPerPixel; + if (_measurementPoint1 != null && _measurementPoint2 != null) { + _measuredDistance = _calculateDistance( + _measurementPoint1!, + _measurementPoint2!, + ); + } + if (changed) { + notifyListeners(); + } + } + + /// Set drawing mode + void setDrawingMode(DrawingMode mode) { + if (_drawingMode != mode) { + // Cancel any in-progress drawing when switching modes + _cancelCurrentDrawing(); + _drawingMode = mode; + notifyListeners(); + } + } + + /// Set selected color + void setColor(Color color) { + _selectedColor = color; + notifyListeners(); + } + + /// Toggle visibility of received drawings + Future toggleReceivedDrawings() async { + _showReceivedDrawings = !_showReceivedDrawings; + notifyListeners(); + await _savePreferences(); + } + + /// Toggle visibility of SAR markers + Future toggleSarMarkers() async { + _showSarMarkers = !_showSarMarkers; + notifyListeners(); + await _savePreferences(); + } + + Future _loadPreferences() async { + final prefs = await SharedPreferences.getInstance(); + _showReceivedDrawings = + prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true; + _showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true; + notifyListeners(); + } + + Future _savePreferences() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + _scopedKey(_showReceivedDrawingsKey), + _showReceivedDrawings, + ); + await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers); + } + + /// Start drawing a line + void startLine(LatLng point) { + if (_drawingMode != DrawingMode.line) return; + + _currentLinePoints = [point]; + notifyListeners(); + } + + /// Add point to current line + void addLinePoint(LatLng point) { + if (_drawingMode != DrawingMode.line || _currentLinePoints.isEmpty) return; + + _currentLinePoints.add(point); + notifyListeners(); + } + + /// Complete current line drawing + void completeLine() { + if (_drawingMode != DrawingMode.line || _currentLinePoints.length < 2) { + _cancelCurrentDrawing(); + return; + } + + final drawing = LineDrawing( + id: DateTime.now().millisecondsSinceEpoch.toString(), + color: _selectedColor, + createdAt: DateTime.now(), + points: List.from(_currentLinePoints), + coordinateSpace: _activeCoordinateSpace, + mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap + ? _activeMapId + : null, + ); + + _drawings.add(drawing); + _currentLinePoints = []; + _saveDrawings(); + notifyListeners(); + } + + /// Start drawing a rectangle + void startRectangle(LatLng point) { + if (_drawingMode != DrawingMode.rectangle) return; + + _rectangleStartPoint = point; + notifyListeners(); + } + + /// Update rectangle end point (for preview) + void updateRectangleEndPoint(LatLng endPoint) { + if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) { + return; + } + + // Create preview rectangle + _currentDrawing = RectangleDrawing( + id: 'preview', + color: _selectedColor, + createdAt: DateTime.now(), + topLeft: LatLng( + _rectangleStartPoint!.latitude > endPoint.latitude + ? endPoint.latitude + : _rectangleStartPoint!.latitude, + _rectangleStartPoint!.longitude < endPoint.longitude + ? _rectangleStartPoint!.longitude + : endPoint.longitude, + ), + bottomRight: LatLng( + _rectangleStartPoint!.latitude < endPoint.latitude + ? endPoint.latitude + : _rectangleStartPoint!.latitude, + _rectangleStartPoint!.longitude > endPoint.longitude + ? _rectangleStartPoint!.longitude + : endPoint.longitude, + ), + coordinateSpace: _activeCoordinateSpace, + mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap + ? _activeMapId + : null, + ); + notifyListeners(); + } + + /// Complete current rectangle drawing + void completeRectangle(LatLng endPoint) { + if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) { + _cancelCurrentDrawing(); + return; + } + + // Calculate top-left and bottom-right corners + final topLeft = LatLng( + _rectangleStartPoint!.latitude > endPoint.latitude + ? endPoint.latitude + : _rectangleStartPoint!.latitude, + _rectangleStartPoint!.longitude < endPoint.longitude + ? _rectangleStartPoint!.longitude + : endPoint.longitude, + ); + + final bottomRight = LatLng( + _rectangleStartPoint!.latitude < endPoint.latitude + ? endPoint.latitude + : _rectangleStartPoint!.latitude, + _rectangleStartPoint!.longitude > endPoint.longitude + ? _rectangleStartPoint!.longitude + : endPoint.longitude, + ); + + final drawing = RectangleDrawing( + id: DateTime.now().millisecondsSinceEpoch.toString(), + color: _selectedColor, + createdAt: DateTime.now(), + topLeft: topLeft, + bottomRight: bottomRight, + coordinateSpace: _activeCoordinateSpace, + mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap + ? _activeMapId + : null, + ); + + _drawings.add(drawing); + _rectangleStartPoint = null; + _currentDrawing = null; + _saveDrawings(); + notifyListeners(); + } + + /// Set first measurement point + void setMeasurementPoint1(LatLng point) { + if (_drawingMode != DrawingMode.measure) return; + + _measurementPoint1 = point; + _measurementPoint2 = null; + _measuredDistance = null; + notifyListeners(); + } + + /// Set second measurement point and calculate distance + void setMeasurementPoint2(LatLng point) { + if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) { + return; + } + + _measurementPoint2 = point; + _measuredDistance = _calculateDistance(_measurementPoint1!, point); + notifyListeners(); + } + + /// Calculate distance between two points using Haversine formula + double _calculateDistance(LatLng point1, LatLng point2) { + if (_activeCoordinateSpace == MapCoordinateSpace.customMap && + _activeMetersPerPixel != null) { + final dy = point2.latitude - point1.latitude; + final dx = point2.longitude - point1.longitude; + final pixelDistance = math.sqrt((dx * dx) + (dy * dy)); + return pixelDistance * _activeMetersPerPixel!; + } + const Distance distance = Distance(); + return distance.as(LengthUnit.Meter, point1, point2); + } + + /// Clear measurement points + void clearMeasurement() { + _measurementPoint1 = null; + _measurementPoint2 = null; + _measuredDistance = null; + notifyListeners(); + } + + /// Cancel current drawing in progress + void _cancelCurrentDrawing() { + _currentLinePoints = []; + _rectangleStartPoint = null; + _currentDrawing = null; + _measurementPoint1 = null; + _measurementPoint2 = null; + _measuredDistance = null; + } + + /// Clear current drawing (public method) + void cancelCurrentDrawing() { + _cancelCurrentDrawing(); + notifyListeners(); + } + + /// Remove a specific drawing + void removeDrawing(String id) { + _drawings.removeWhere((d) => d.id == id); + _saveDrawings(); + notifyListeners(); + } + + /// Clear all drawings + void clearAllDrawings() { + _drawings.clear(); + _cancelCurrentDrawing(); + _saveDrawings(); + notifyListeners(); + } + + /// Exit drawing mode + void exitDrawingMode() { + _cancelCurrentDrawing(); + _drawingMode = DrawingMode.none; + notifyListeners(); + } + + /// Save drawings to persistent storage + Future _saveDrawings() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonList = _drawings.map((d) => d.toJson()).toList(); + final jsonString = jsonEncode(jsonList); + await prefs.setString(_scopedKey(_storageKey), jsonString); + } catch (e) { + debugPrint('Error saving drawings: $e'); + } + } + + /// Load drawings from persistent storage + Future _loadDrawings() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_scopedKey(_storageKey)); + if (jsonString == null || jsonString.isEmpty) { + _drawings.clear(); + return; + } + + final jsonList = jsonDecode(jsonString) as List; + _drawings.clear(); + + for (final json in jsonList) { + final drawing = MapDrawing.fromJson(json as Map); + if (drawing != null) { + _drawings.add(drawing); + } + } + + notifyListeners(); + } catch (e) { + debugPrint('Error loading drawings: $e'); + } + } + + /// Get the current preview drawing for rendering + MapDrawing? getPreviewDrawing() { + if (_drawingMode == DrawingMode.line && _currentLinePoints.length >= 2) { + return LineDrawing( + id: 'preview', + color: _selectedColor, + createdAt: DateTime.now(), + points: _currentLinePoints, + coordinateSpace: _activeCoordinateSpace, + mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap + ? _activeMapId + : null, + ); + } else if (_drawingMode == DrawingMode.rectangle && + _currentDrawing != null) { + return _currentDrawing; + } + return null; + } + + /// Add received drawing from another node + void addReceivedDrawing(MapDrawing drawing) { + // Check if drawing with this ID already exists + if (_drawings.any((d) => d.id == drawing.id)) { + debugPrint('Drawing ${drawing.id} already exists, skipping'); + return; + } + + // Mark as received when adding + final receivedDrawing = _createReceivedCopy(drawing); + _drawings.add(receivedDrawing); + _saveDrawings(); + notifyListeners(); + } + + /// Create a copy of a drawing marked as received + MapDrawing _createReceivedCopy(MapDrawing drawing) { + if (drawing is LineDrawing) { + return LineDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + points: drawing.points, + senderName: drawing.senderName, + isReceived: true, + messageId: drawing.messageId, + isShared: drawing.isShared, + isSent: drawing.isSent, + isHidden: drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, + ); + } else if (drawing is RectangleDrawing) { + return RectangleDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + topLeft: drawing.topLeft, + bottomRight: drawing.bottomRight, + senderName: drawing.senderName, + isReceived: true, + messageId: drawing.messageId, + isShared: drawing.isShared, + isSent: drawing.isSent, + isHidden: drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, + ); + } + return drawing; + } + + /// Get a drawing by its ID + MapDrawing? getDrawingById(String id) { + try { + return _drawings.firstWhere((d) => d.id == id); + } catch (e) { + return null; + } + } + + List> exportDrawingsJson() { + return _drawings.map((drawing) => drawing.toJson()).toList(); + } + + Future replaceDrawingsFromJson( + List> jsonList, + ) async { + _drawings + ..clear() + ..addAll(jsonList.map(MapDrawing.fromJson).whereType()); + await _saveDrawings(); + notifyListeners(); + } + + String _scopedKey(String baseKey) { + return ProfileStorageScope.scopedKey(baseKey); + } + + /// Get all unshared drawings (local drawings not yet sent) + List getUnsharedDrawings() { + return drawings.where((d) => !d.isShared && !d.isReceived).toList(); + } + + /// Mark a drawing as shared + void markDrawingAsShared(String id) { + final index = _drawings.indexWhere((d) => d.id == id); + if (index != -1) { + final drawing = _drawings[index]; + + // Create a copy with isShared = true + if (drawing is LineDrawing) { + _drawings[index] = LineDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + points: drawing.points, + senderName: drawing.senderName, + isReceived: drawing.isReceived, + messageId: drawing.messageId, + isShared: true, + isSent: drawing.isSent, + isHidden: drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, + ); + } else if (drawing is RectangleDrawing) { + _drawings[index] = RectangleDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + topLeft: drawing.topLeft, + bottomRight: drawing.bottomRight, + senderName: drawing.senderName, + isReceived: drawing.isReceived, + messageId: drawing.messageId, + isShared: true, + isSent: drawing.isSent, + isHidden: drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, + ); + } + + _saveDrawings(); + notifyListeners(); + } + } + + /// Toggle visibility of a drawing (doesn't save to storage) + void toggleDrawingVisibility(String id) { + final index = _drawings.indexWhere((d) => d.id == id); + if (index != -1) { + final drawing = _drawings[index]; + + // Create a copy with toggled isHidden flag + if (drawing is LineDrawing) { + _drawings[index] = LineDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + points: drawing.points, + senderName: drawing.senderName, + isReceived: drawing.isReceived, + messageId: drawing.messageId, + isShared: drawing.isShared, + isSent: drawing.isSent, + isHidden: !drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, + ); + } else if (drawing is RectangleDrawing) { + _drawings[index] = RectangleDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + topLeft: drawing.topLeft, + bottomRight: drawing.bottomRight, + senderName: drawing.senderName, + isReceived: drawing.isReceived, + messageId: drawing.messageId, + isShared: drawing.isShared, + isSent: drawing.isSent, + isHidden: !drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, + ); + } + + // Don't save to storage - visibility toggle is temporary + notifyListeners(); + } + } + + /// Remove a drawing and its linked message + void removeDrawingAndMessage(String drawingId, dynamic messagesProvider) { + final drawing = getDrawingById(drawingId); + if (drawing == null) return; + + // Remove the drawing + _drawings.removeWhere((d) => d.id == drawingId); + + // If the drawing has a linked message, remove it too + if (drawing.messageId != null && messagesProvider != null) { + messagesProvider.deleteMessage(drawing.messageId!); + } + + _saveDrawings(); + notifyListeners(); + } + + /// Broadcast a drawing to contacts + /// Returns the formatted message string ready to send + /// Sender will be determined from packet metadata on receiving end + String createDrawingBroadcastMessage(MapDrawing drawing) { + return DrawingMessageParser.createDrawingMessage(drawing); + } +} diff --git a/lib/providers/helpers/fragment_ack_wait_registry.dart b/lib/providers/helpers/fragment_ack_wait_registry.dart new file mode 100644 index 0000000..2f2711c --- /dev/null +++ b/lib/providers/helpers/fragment_ack_wait_registry.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +/// Tracks one or more in-flight waiters for the same fragment ACK key. +/// +/// Duplicate fetch requests can race and wait on the same fragment ACK at once. +/// Completing all registered waiters avoids losing the earlier completer when a +/// later request registers for the same key. +class FragmentAckWaitRegistry { + final Map>> _waiters = {}; + + Future waitFor( + String key, { + Duration timeout = const Duration(seconds: 8), + }) async { + final completer = Completer(); + final waiters = _waiters.putIfAbsent(key, () => >[]); + waiters.add(completer); + try { + await completer.future.timeout(timeout); + return true; + } catch (_) { + final pending = _waiters[key]; + pending?.remove(completer); + if (pending != null && pending.isEmpty) { + _waiters.remove(key); + } + return false; + } + } + + int complete(String key) { + final waiters = _waiters.remove(key); + if (waiters == null || waiters.isEmpty) { + return 0; + } + for (final completer in waiters) { + if (!completer.isCompleted) { + completer.complete(); + } + } + return waiters.length; + } +} diff --git a/lib/providers/helpers/message_delivery_tracker.dart b/lib/providers/helpers/message_delivery_tracker.dart new file mode 100644 index 0000000..c2e5e58 --- /dev/null +++ b/lib/providers/helpers/message_delivery_tracker.dart @@ -0,0 +1,234 @@ +import 'dart:async'; +import 'dart:typed_data'; + +/// Message delivery tracking helper +/// +/// Manages message delivery tracking for sent messages, including: +/// - FIFO queue for matching RESP_CODE_SENT with message IDs +/// - ACK tag to message ID mapping +/// - Timeout tracking for stale ACK mappings +/// - Message sent/delivered coordination +/// +/// IMPORTANT: Based on MeshCore firmware analysis: +/// - Firmware tracks max 8 pending ACKs in circular buffer +/// - ACK entries overwritten after 8 messages → need rate limiting +/// - Duplicate ACKs suppressed after first match +/// - No automatic retry → app must implement +class MessageDeliveryTracker { + /// FIFO queue of pending message IDs + /// Messages tracked here before sending, popped when RESP_CODE_SENT arrives + final List _pendingMessageIds = []; + + /// Contact-scoped FIFOs for matching direct-message SENT responses. + final Map> _pendingMessageIdsByContact = {}; + + /// Map of ACK tag to message ID for delivery confirmation + final Map _ackTagToMessageId = {}; + + /// Map of message ID to ACK tag (reverse mapping for cleanup) + final Map _messageIdToAckTag = {}; + + /// Map of ACK tag to timestamp for timeout cleanup + final Map _ackTagTimestamps = {}; + + /// Completer signalled when a pending ACK slot is freed (delivery or removal). + Completer? _slotFreedCompleter; + + /// Track a pending message ID before sending + /// + /// This is called BEFORE sending the message. When RESP_CODE_SENT + /// arrives, we pop from this FIFO queue to match with the ACK tag. + void trackPendingMessage(String messageId) { + _pendingMessageIds.add(messageId); + } + + /// Track a pending direct message ID for a specific contact. + void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) { + trackPendingMessage(messageId); + final contactKey = _contactKey(contactPublicKey); + _pendingMessageIdsByContact + .putIfAbsent(contactKey, () => []) + .add(messageId); + } + + /// Pop the next pending message ID from FIFO queue + /// + /// Called when RESP_CODE_SENT arrives. Returns null if queue empty. + String? popPendingMessageId() { + if (_pendingMessageIds.isEmpty) { + return null; + } + return _pendingMessageIds.removeAt(0); + } + + /// Pop the next pending direct message ID for a specific contact. + /// + /// Falls back to the legacy global FIFO if the contact queue is empty. + String? popPendingDirectMessageId(Uint8List contactPublicKey) { + final contactKey = _contactKey(contactPublicKey); + final queue = _pendingMessageIdsByContact[contactKey]; + if (queue == null || queue.isEmpty) { + return popPendingMessageId(); + } + + final messageId = queue.removeAt(0); + if (queue.isEmpty) { + _pendingMessageIdsByContact.remove(contactKey); + } + _pendingMessageIds.remove(messageId); + return messageId; + } + + /// Map ACK tag to message ID after RESP_CODE_SENT received + /// + /// Creates bidirectional mapping for efficient cleanup and tracking. + /// + /// WARNING: Firmware only tracks 8 pending ACKs! Caller should + /// enforce rate limiting before calling this. + void mapAckTagToMessageId(int ackTag, String messageId) { + // Store bidirectional mapping + _ackTagToMessageId[ackTag] = messageId; + _messageIdToAckTag[messageId] = ackTag; + _ackTagTimestamps[ackTag] = DateTime.now(); + } + + /// Get message ID for ACK code + /// + /// Called when SEND_CONFIRMED arrives. Returns the message ID + /// that corresponds to this ACK code. + /// + /// Returns null if ACK tag not found. + String? getMessageIdForAck(int ackCode) { + return _ackTagToMessageId[ackCode]; + } + + /// Returns true once a message has been matched to a concrete ACK tag. + bool hasAckForMessage(String messageId) { + return _messageIdToAckTag.containsKey(messageId); + } + + /// Remove ACK tag mapping after delivery confirmed or timeout + /// + /// Cleans up both forward and reverse mappings. + void removeAckTag(int ackCode) { + final messageId = _ackTagToMessageId.remove(ackCode); + if (messageId != null) { + _messageIdToAckTag.remove(messageId); + } + _ackTagTimestamps.remove(ackCode); + _notifySlotFreed(); + } + + /// Remove ACK tag mapping by message ID + /// + /// Used when message times out or is cancelled. + void removeByMessageId(String messageId) { + final ackTag = _messageIdToAckTag.remove(messageId); + if (ackTag != null) { + _ackTagToMessageId.remove(ackTag); + _ackTagTimestamps.remove(ackTag); + _notifySlotFreed(); + } + _pendingMessageIds.remove(messageId); + final emptyKeys = []; + for (final entry in _pendingMessageIdsByContact.entries) { + entry.value.remove(messageId); + if (entry.value.isEmpty) { + emptyKeys.add(entry.key); + } + } + for (final key in emptyKeys) { + _pendingMessageIdsByContact.remove(key); + } + } + + /// Clean up stale ACK mappings + /// + /// Removes ACK tags that haven't received delivery confirmation + /// within the specified timeout (default: 5 minutes). + /// + /// Returns count of cleaned up entries. + int cleanupStaleAcks({Duration timeout = const Duration(minutes: 5)}) { + final now = DateTime.now(); + final staleAcks = []; + + for (final entry in _ackTagTimestamps.entries) { + if (now.difference(entry.value) > timeout) { + staleAcks.add(entry.key); + } + } + + for (final ackTag in staleAcks) { + removeAckTag(ackTag); + } + + return staleAcks.length; + } + + /// Clear all tracking state + void clearTracking() { + _pendingMessageIds.clear(); + _pendingMessageIdsByContact.clear(); + _ackTagToMessageId.clear(); + _messageIdToAckTag.clear(); + _ackTagTimestamps.clear(); + _notifySlotFreed(); + } + + /// Get count of pending ACK tags + /// + /// WARNING: Firmware only tracks 8 pending ACKs in circular buffer. + /// If this exceeds 7, message sending should be rate limited. + int get pendingCount => _ackTagToMessageId.length; + + /// Check if should rate limit message sending + /// + /// Returns true if >= 7 pending ACKs (stay under firmware limit of 8) + bool get shouldRateLimit => pendingCount >= 7; + + /// Wait until a pending ACK slot is freed, or [timeout] elapses. + /// + /// Returns immediately if not at the rate limit. + Future waitForSlot({ + Duration timeout = const Duration(milliseconds: 500), + }) async { + if (!shouldRateLimit) return; + _slotFreedCompleter ??= Completer(); + await _slotFreedCompleter!.future.timeout( + timeout, + onTimeout: () {}, + ); + } + + void _notifySlotFreed() { + if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) { + _slotFreedCompleter!.complete(); + } + _slotFreedCompleter = null; + } + + /// Get oldest pending ACK timestamp (for debugging) + DateTime? get oldestPendingTimestamp { + if (_ackTagTimestamps.isEmpty) return null; + return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b); + } + + /// Get diagnostic info for debugging + Map getDiagnostics() { + return { + 'pendingCount': pendingCount, + 'shouldRateLimit': shouldRateLimit, + 'oldestPending': oldestPendingTimestamp?.toIso8601String(), + 'ackTags': _ackTagToMessageId.keys.toList(), + 'pendingByContact': _pendingMessageIdsByContact.map( + (key, value) => MapEntry(key, value.length), + ), + }; + } + + String _contactKey(Uint8List contactPublicKey) { + return contactPublicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + } +} diff --git a/lib/providers/helpers/message_retry_manager.dart b/lib/providers/helpers/message_retry_manager.dart new file mode 100644 index 0000000..330ac5e --- /dev/null +++ b/lib/providers/helpers/message_retry_manager.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import '../../models/message.dart'; +import '../../models/contact.dart'; + +/// Manages message retry state and logic +/// +/// This helper class centralizes retry logic for direct messages. +/// +/// IMPORTANT: Based on MeshCore firmware analysis: +/// - Firmware calculates timeout based on path length and airtime +/// - Direct mode: ~(path_len * airtime * 2) + margin +/// - Flood mode: ~10-30 seconds for multi-hop +/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers +/// - Firmware does NOT automatically retry - app must implement +class MessageRetryManager { + // Track retry state for each message ID + final Map _retryAttempts = {}; + final Map _lastRetryTimes = {}; + final Map _pathFailureStreaks = {}; + + /// Max retry attempts when the contact has a known path. + /// Sequence: 2 direct attempts, flood, then last successful route. + static const int maxRetryAttemptsWithPath = 3; + + /// No retries for flood-only contacts (no known path). + /// Value 0 means: don't retry at all, go straight to fallback/fail. + static const int maxRetryAttemptsFloodOnly = 0; + + @Deprecated('Use maxRetryAttemptsForContact instead') + static const int maxRetryAttempts = maxRetryAttemptsWithPath; + + // Retry backoff values in milliseconds. + static const List _retryDelays = [1000, 2000, 4000, 8000, 8000]; + static const int _defaultLoRaSf = 10; + static const int _defaultLoRaCr = 5; + static const int _defaultLoRaBwHz = 250000; + static const int _defaultLoRaPreambleSymbols = 8; + static const int _defaultLoRaCrcEnabled = 1; + static const int _defaultLoRaExplicitHeader = 1; + + /// Get backoff delay for the next retry attempt. + int getDelayForAttempt(int attempt) { + if (attempt < 0 || attempt >= _retryDelays.length) { + return _retryDelays.last; + } + return _retryDelays[attempt]; + } + + static final math.Random _rng = math.Random(); + + /// Calculate a delivery-ACK timeout with random jitter. + /// + /// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`. + /// The jitter prevents collision when multiple messages are in flight. + int calculateAckTimeoutMs({ + required String text, + required Contact? contact, + int? suggestedTimeoutMs, + }) { + int baseTimeout; + if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) { + baseTimeout = suggestedTimeoutMs; + } else { + final payloadBytes = utf8.encode(text).length; + final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes); + final hopCount = contact?.routeHasPath == true + ? contact!.routeHopCount + : -1; + + if (hopCount < 0) { + baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000); + } else { + baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000); + } + } + + // Add random jitter: 500-2000ms to avoid collision + final jitterMs = 500 + _rng.nextInt(1501); + return baseTimeout + jitterMs; + } + + /// Max attempts for a given contact based on whether it has a known path. + static int maxRetryAttemptsForContact(Contact? contact) { + final hasPath = contact?.routeHasPath ?? false; + return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly; + } + + bool canRetry(Message message, Contact contact) { + return message.retryAttempt < maxRetryAttemptsForContact(contact); + } + + /// Whether this is the last retry attempt. + /// When true, the caller should reset the path to force flood mode. + /// + /// Note: called AFTER retryAttempt has been incremented, so we compare + /// directly against maxAttempts (not +1). + bool isLastAttempt(Message message, Contact contact) { + final maxAttempts = maxRetryAttemptsForContact(contact); + return maxAttempts > 1 && message.retryAttempt >= maxAttempts; + } + + /// Track a retry attempt for a message + void trackRetry(String messageId, int attempt) { + _retryAttempts[messageId] = attempt; + _lastRetryTimes[messageId] = DateTime.now(); + } + + /// Clear retry tracking for a message (on success or permanent failure) + void clearRetry(String messageId) { + _retryAttempts.remove(messageId); + _lastRetryTimes.remove(messageId); + } + + /// Clear all retry tracking (on disconnect) + void clearAll() { + _retryAttempts.clear(); + _lastRetryTimes.clear(); + _pathFailureStreaks.clear(); + } + + /// Get current retry attempt for a message (for debugging) + int? getRetryAttempt(String messageId) { + return _retryAttempts[messageId]; + } + + /// Get last retry time for a message (for debugging) + DateTime? getLastRetryTime(String messageId) { + return _lastRetryTimes[messageId]; + } + + /// Record a successful delivery for a contact and clear any accumulated + /// route failure streak for future sends. + void recordDeliverySuccess(Contact contact) { + _pathFailureStreaks.remove(contact.publicKeyHex); + } + + /// Record a permanent route failure for a contact. + /// + /// Returns the updated failure streak so callers can decide when to reset + /// the learned path on the radio and in local state. + int recordPathFailure(Contact contact) { + final contactKey = contact.publicKeyHex; + final next = (_pathFailureStreaks[contactKey] ?? 0) + 1; + _pathFailureStreaks[contactKey] = next; + return next; + } + + int? getPathFailureStreak(Contact contact) { + return _pathFailureStreaks[contact.publicKeyHex]; + } + + int _estimateLoRaAirtimeMs(int payloadLenBytes) { + final sf = _defaultLoRaSf; + final bw = _defaultLoRaBwHz.toDouble(); + final cr = (_defaultLoRaCr - 4).clamp(1, 4); + final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1; + final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0; + + final symbolMs = ((1 << sf) / bw) * 1000.0; + final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs; + + final num = + (8 * payloadLenBytes) - + (4 * sf) + + 28 + + (16 * _defaultLoRaCrcEnabled) - + (20 * ih); + final den = 4 * (sf - (2 * de)); + final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil(); + final payloadSymbols = + 8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4); + final payloadMs = payloadSymbols * symbolMs; + + return (preambleMs + payloadMs).ceil(); + } +} diff --git a/lib/providers/helpers/ping_tracker.dart b/lib/providers/helpers/ping_tracker.dart new file mode 100644 index 0000000..cca4b21 --- /dev/null +++ b/lib/providers/helpers/ping_tracker.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; + +/// Helper class to track pending ping (telemetry) requests +/// and implement automatic fallback to flooding if no response received +class PingTracker { + // Map of public key hex string to ping request state + final Map _pendingPings = {}; + + // Timeout duration for ping responses (seconds) + static const int _pingTimeoutSeconds = 5; + + /// Track a new ping request + /// Returns a Future that completes when either: + /// - A response is received (completes with true) + /// - Timeout occurs (completes with false) + Future trackPing({ + required Uint8List publicKey, + required bool wasDirectAttempt, + }) { + final String keyHex = _publicKeyToHex(publicKey); + + // Cancel any existing pending ping for this contact + _pendingPings[keyHex]?.cancel(); + + // Create new ping request tracker + final completer = Completer(); + final timer = Timer(const Duration(seconds: _pingTimeoutSeconds), () { + // Timeout occurred - mark as failed + _pendingPings.remove(keyHex); + if (!completer.isCompleted) { + completer.complete(false); + } + }); + + _pendingPings[keyHex] = _PingRequest( + publicKey: publicKey, + wasDirectAttempt: wasDirectAttempt, + timer: timer, + completer: completer, + ); + + return completer.future; + } + + /// Mark a ping as successful (response received) + /// Should be called when telemetry response arrives + void markPingSuccessful(Uint8List publicKey) { + final requestKey = _findMatchingPendingPingKey(publicKey); + final request = requestKey != null ? _pendingPings.remove(requestKey) : null; + + if (request != null) { + request.cancel(); + if (!request.completer.isCompleted) { + request.completer.complete(true); + } + } + } + + /// Check if there's a pending ping for this contact + bool hasPendingPing(Uint8List publicKey) { + final String keyHex = _publicKeyToHex(publicKey); + return _pendingPings.containsKey(keyHex); + } + + /// Get pending ping info (was it a direct attempt?) + bool? wasPingDirect(Uint8List publicKey) { + final String keyHex = _publicKeyToHex(publicKey); + return _pendingPings[keyHex]?.wasDirectAttempt; + } + + /// Clear all pending pings (useful on disconnect) + void clearAll() { + for (final request in _pendingPings.values) { + request.cancel(); + } + _pendingPings.clear(); + } + + /// Convert public key to hex string for map key + String _publicKeyToHex(Uint8List publicKey) { + return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + } + + String? _findMatchingPendingPingKey(Uint8List responseKey) { + final responseHex = _publicKeyToHex(responseKey); + + if (_pendingPings.containsKey(responseHex)) { + return responseHex; + } + + for (final entry in _pendingPings.entries) { + final pendingHex = entry.key; + if (pendingHex.startsWith(responseHex) || responseHex.startsWith(pendingHex)) { + return pendingHex; + } + } + + return null; + } +} + +/// Internal class to track a single ping request +class _PingRequest { + final Uint8List publicKey; + final bool wasDirectAttempt; + final Timer timer; + final Completer completer; + + _PingRequest({ + required this.publicKey, + required this.wasDirectAttempt, + required this.timer, + required this.completer, + }); + + void cancel() { + timer.cancel(); + } +} diff --git a/lib/providers/helpers/raw_session_retransmit.dart b/lib/providers/helpers/raw_session_retransmit.dart new file mode 100644 index 0000000..ef669d8 --- /dev/null +++ b/lib/providers/helpers/raw_session_retransmit.dart @@ -0,0 +1,90 @@ +import 'package:flutter/foundation.dart'; + +import '../../models/contact.dart'; + +typedef RawPacketSender = + Future Function({ + required Uint8List contactPath, + required int contactPathLen, + required Uint8List payload, + }); + +/// Pacing between consecutive raw fragments. Firmware v1.15 rejects bursts of +/// raw packets with `ERROR: Table full` when fragments are blasted too quickly, +/// so we space them out to keep the radio's raw transmit queue from overflowing. +const Duration _interFragmentDelay = Duration(milliseconds: 350); + +Future serveCachedSessionFragments({ + required String providerLabel, + required String sessionId, + required Contact requester, + required List fragments, + required int maxDirectPayloadHops, + required int Function(T fragment) indexOf, + required Uint8List Function(T fragment) encodeBinary, + required RawPacketSender? sendRawPacket, + Set? requestedIndices, + Duration interFragmentDelay = _interFragmentDelay, +}) async { + if (fragments.isEmpty) { + debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId'); + return false; + } + if (sendRawPacket == null) { + debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set'); + return false; + } + if (!requester.routeHasPath) { + debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path'); + return false; + } + if (requester.routeHopCount > maxDirectPayloadHops) { + debugPrint( + '⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.routeHopCount} hops (max $maxDirectPayloadHops)', + ); + return false; + } + if (requester.outPath.isEmpty) { + debugPrint( + '⚠️ [$providerLabel] ${requester.advName} has empty outPath payload', + ); + return false; + } + + var servedCount = 0; + for (final fragment in fragments) { + final index = indexOf(fragment); + if (index < 0) { + debugPrint('⚠️ [$providerLabel] Invalid fragment index $index'); + continue; + } + if (requestedIndices != null && !requestedIndices.contains(index)) { + continue; + } + try { + if (servedCount > 0 && interFragmentDelay > Duration.zero) { + await Future.delayed(interFragmentDelay); + } + await sendRawPacket( + contactPath: requester.outPath, + contactPathLen: requester.routeEncodedPathLen, + payload: encodeBinary(fragment), + ); + servedCount++; + } catch (e, st) { + debugPrint( + '❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st', + ); + return false; + } + } + + if (servedCount == 0) { + debugPrint( + '⚠️ [$providerLabel] No fragments matched request for $sessionId', + ); + return false; + } + debugPrint('✅ [$providerLabel] Served $servedCount fragments for $sessionId'); + return true; +} diff --git a/lib/providers/helpers/room_login_manager.dart b/lib/providers/helpers/room_login_manager.dart new file mode 100644 index 0000000..8dc9f4d --- /dev/null +++ b/lib/providers/helpers/room_login_manager.dart @@ -0,0 +1,84 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../models/room_login_state.dart'; + +/// Room login state management helper +/// +/// Manages login state tracking for room contacts, including: +/// - Room login state per contact (Map of String to RoomLoginState) +/// - Password checking logic +/// - Login success/fail state updates +class RoomLoginManager { + /// Map of room public key prefix (hex string) to login state + final Map _roomLoginStates = {}; + + /// Get all room login states (unmodifiable view) + Map get roomLoginStates => Map.unmodifiable(_roomLoginStates); + + /// Get login state for a room by public key prefix + RoomLoginState? getRoomLoginState(Uint8List publicKeyPrefix) { + final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix); + return _roomLoginStates[prefixHex]; + } + + /// Check if logged into a specific room + bool isLoggedIntoRoom(Uint8List publicKeyPrefix) { + final state = getRoomLoginState(publicKeyPrefix); + return state?.isLoggedIn ?? false; + } + + /// Update room login state after successful login + Future handleLoginSuccess({ + required Uint8List publicKeyPrefix, + required int permissions, + required bool isAdmin, + required int tag, + }) async { + final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix); + final hasPassword = await _hasPasswordForRoom(publicKeyPrefix); + + _roomLoginStates[prefixHex] = RoomLoginState.loggedIn( + publicKeyPrefix: publicKeyPrefix, + permissions: permissions, + isAdmin: isAdmin, + tag: tag, + hasPassword: hasPassword, + ); + } + + /// Update room login state after failed login + void handleLoginFail({ + required Uint8List publicKeyPrefix, + }) { + final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix); + + _roomLoginStates[prefixHex] = RoomLoginState.loggedOut( + publicKeyPrefix: publicKeyPrefix, + hasPassword: false, // Password was incorrect + ); + } + + /// Clear all room login states (call on disconnect) + void clearRoomLoginStates() { + _roomLoginStates.clear(); + } + + /// Check if a password exists for a room (by public key prefix) + Future _hasPasswordForRoom(Uint8List publicKeyPrefix) async { + try { + final prefs = await SharedPreferences.getInstance(); + // Convert prefix to hex string for storage key + final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix); + final roomKey = 'room_password_$prefixHex'; + return prefs.getString(roomKey) != null; + } catch (e) { + debugPrint('Error checking password for room: $e'); + return false; + } + } + + /// Convert public key prefix to hex string (colon-separated) + String _publicKeyPrefixToHex(Uint8List publicKeyPrefix) { + return publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + } +} diff --git a/lib/providers/helpers/session_metadata_restore.dart b/lib/providers/helpers/session_metadata_restore.dart new file mode 100644 index 0000000..9476867 --- /dev/null +++ b/lib/providers/helpers/session_metadata_restore.dart @@ -0,0 +1,57 @@ +import '../../models/message.dart'; +import '../../utils/image_message_parser.dart'; +import '../../utils/voice_message_parser.dart'; + +class RestoredSessionMetadata { + final Map voiceSenderKeyBySession; + final Map imageSenderKeyBySession; + final Map imageEnvelopeBySession; + + const RestoredSessionMetadata({ + required this.voiceSenderKeyBySession, + required this.imageSenderKeyBySession, + required this.imageEnvelopeBySession, + }); +} + +RestoredSessionMetadata restoreSessionMetadataFromMessages( + Iterable messages, +) { + final voiceSenderKeyBySession = {}; + final imageSenderKeyBySession = {}; + final imageEnvelopeBySession = {}; + + for (final message in messages) { + final text = message.text; + final voiceEnvelope = VoiceEnvelope.tryParseText(text); + if (voiceEnvelope != null) { + final senderPrefix = message.senderPublicKeyPrefix; + if (senderPrefix != null && senderPrefix.length >= 6) { + voiceSenderKeyBySession[voiceEnvelope.sessionId] = senderPrefix + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + } + } + + final imageEnvelope = ImageEnvelope.tryParse(text); + if (imageEnvelope != null) { + imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope; + final senderPrefix = message.senderPublicKeyPrefix; + if (senderPrefix != null && senderPrefix.length >= 6) { + imageSenderKeyBySession[imageEnvelope.sessionId] = senderPrefix + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + } + } + } + + return RestoredSessionMetadata( + voiceSenderKeyBySession: voiceSenderKeyBySession, + imageSenderKeyBySession: imageSenderKeyBySession, + imageEnvelopeBySession: imageEnvelopeBySession, + ); +} diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart new file mode 100644 index 0000000..07bca94 --- /dev/null +++ b/lib/providers/image_provider.dart @@ -0,0 +1,458 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/contact.dart'; +import '../services/profiles_feature_service.dart'; +import 'helpers/raw_session_retransmit.dart'; +import '../utils/image_message_parser.dart'; + +/// Reassembly state for one incoming image session. +class ImageSession { + final String sessionId; + final ImageFormat format; + final int total; + final int width; + final int height; + final List fragments; // indexed by fragment.index + DateTime? firstFragmentAt; + DateTime? lastFragmentAt; + + ImageSession({ + required this.sessionId, + required this.format, + required this.total, + required this.width, + required this.height, + }) : fragments = List.filled(total, null); + + int get receivedCount => fragments.where((f) => f != null).length; + bool get isComplete => receivedCount == total; + + Duration? estimateRemaining() { + if (isComplete) return Duration.zero; + if (firstFragmentAt == null || lastFragmentAt == null) return null; + if (receivedCount < 2) return null; + + final elapsedMs = lastFragmentAt! + .difference(firstFragmentAt!) + .inMilliseconds; + if (elapsedMs <= 0) return null; + final avgMsPerFragment = elapsedMs / (receivedCount - 1); + final remaining = total - receivedCount; + if (remaining <= 0) return Duration.zero; + return Duration(milliseconds: (avgMsPerFragment * remaining).round()); + } + + /// Reassemble the complete image bytes, or null if any fragment is missing. + Uint8List? get imageBytes => reassembleImage(fragments); +} + +/// Manages incoming image sessions and outgoing image caches. +/// +/// Mirrors [VoiceProvider] in architecture: on-demand fetch, deferred serving, +/// persistent storage of both incoming and outgoing session data. +class ImageProvider with ChangeNotifier { + static const String _storageKey = 'stored_image_sessions_v1'; + static const Duration _outgoingTtl = Duration(minutes: 15); + static const int maxDirectPayloadHops = 3; + + /// Incoming sessions keyed by sessionId. + final Map _sessions = {}; + final Set _ignoredIncomingSessions = {}; + + /// Outgoing sessions cached for deferred serving. + final Map _outgoing = {}; + + /// Hook for sending a raw binary payload to a contact. + Future Function({ + required Uint8List contactPath, + required int contactPathLen, + required Uint8List payload, + })? + sendRawPacketCallback; + + ImageProvider() { + _restore(); + } + + String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey); + + // ── Accessors ──────────────────────────────────────────────────────────── + + ImageSession? session(String sessionId) => _sessions[sessionId]; + bool isComplete(String sessionId) => + _sessions[sessionId]?.isComplete ?? false; + bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId); + Duration? estimateRemainingTransferTime(String sessionId) => + _sessions[sessionId]?.estimateRemaining(); + bool isReceiveCanceled(String sessionId) => + _ignoredIncomingSessions.contains(sessionId); + + List missingFragmentIndices(String sessionId) { + final session = _sessions[sessionId]; + if (session == null) return const []; + final missing = []; + for (var i = 0; i < session.total; i++) { + if (session.fragments[i] == null) missing.add(i); + } + return missing; + } + + List availableFragmentIndices(String sessionId) { + final outgoing = _outgoing[sessionId]; + if (outgoing != null) { + return outgoing.fragments.map((fragment) => fragment.index).toList() + ..sort(); + } + + final session = _sessions[sessionId]; + if (session == null) return const []; + final indices = []; + for (var i = 0; i < session.fragments.length; i++) { + if (session.fragments[i] != null) { + indices.add(i); + } + } + return indices; + } + + // ── Incoming fragment reception ────────────────────────────────────────── + + /// Add a received [fragment]. New compact fragments rely on prior envelope + /// metadata for total/format, while legacy fragments can still self-describe. + /// + /// Returns true when the session just became complete. + bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) { + if (_ignoredIncomingSessions.contains(fragment.sessionId)) { + debugPrint( + '⏹️ [ImageProvider] Ignoring canceled incoming session ${fragment.sessionId}', + ); + return false; + } + _sessions.putIfAbsent(fragment.sessionId, () { + if (fragment.total < 1) { + throw StateError( + 'Image envelope missing for compact fragment ${fragment.sessionId}', + ); + } + return ImageSession( + sessionId: fragment.sessionId, + format: fragment.format, + total: fragment.total, + width: width, + height: height, + ); + }); + + final session = _sessions[fragment.sessionId]!; + if (fragment.index < session.total) { + final wasMissing = session.fragments[fragment.index] == null; + session.fragments[fragment.index] = fragment; + if (wasMissing) { + final now = DateTime.now(); + session.firstFragmentAt ??= now; + session.lastFragmentAt = now; + } + } + + final justComplete = session.isComplete; + unawaited(_persist()); + notifyListeners(); + return justComplete; + } + + void cancelIncomingSession(String sessionId) { + _ignoredIncomingSessions.add(sessionId); + _sessions.remove(sessionId); + unawaited(_persist()); + notifyListeners(); + } + + void resumeIncomingSession(String sessionId) { + if (_ignoredIncomingSessions.remove(sessionId)) { + notifyListeners(); + } + } + + /// Register envelope metadata for a session (called when IE1 is received + /// before any binary fragments arrive). + void registerEnvelope(ImageEnvelope envelope) { + if (_ignoredIncomingSessions.contains(envelope.sessionId)) { + return; + } + final existing = _sessions[envelope.sessionId]; + if (existing == null) { + _sessions[envelope.sessionId] = ImageSession( + sessionId: envelope.sessionId, + format: envelope.format, + total: envelope.total, + width: envelope.width, + height: envelope.height, + ); + unawaited(_persist()); + notifyListeners(); + return; + } + + final needsMerge = + existing.width == 0 || + existing.height == 0 || + existing.total != envelope.total || + existing.format != envelope.format; + if (!needsMerge) { + notifyListeners(); + return; + } + + final merged = ImageSession( + sessionId: envelope.sessionId, + format: envelope.format, + total: envelope.total, + width: envelope.width, + height: envelope.height, + ); + merged.firstFragmentAt = existing.firstFragmentAt; + merged.lastFragmentAt = existing.lastFragmentAt; + for (final fragment in existing.fragments) { + if (fragment == null) continue; + if (fragment.index < merged.total) { + merged.fragments[fragment.index] = fragment; + } + } + _sessions[envelope.sessionId] = merged; + unawaited(_persist()); + notifyListeners(); + } + + // ── Outgoing session management ────────────────────────────────────────── + + /// Cache encoded fragments for deferred serving. + /// + /// Also registers the session as complete in [_sessions] so the local + /// bubble can display the sent image immediately without a fetch round-trip. + void cacheOutgoingSession( + String sessionId, + List fragments, + ImageEnvelope envelope, + ) { + if (fragments.isEmpty) return; + _evictExpiredOutgoing(); + _outgoing[sessionId] = _OutgoingSession( + sessionId: sessionId, + fragments: List.from(fragments), + envelope: envelope, + cachedAt: DateTime.now(), + ); + + // Populate incoming session so the bubble shows the image right away. + final session = ImageSession( + sessionId: sessionId, + format: envelope.format, + total: fragments.length, + width: envelope.width, + height: envelope.height, + ); + for (final f in fragments) { + if (f.index < session.total) session.fragments[f.index] = f; + } + _sessions[sessionId] = session; + + unawaited(_persist()); + notifyListeners(); + } + + /// Stream cached image fragments to [requester] via raw binary packets. + Future serveSessionTo({ + required String sessionId, + required Contact requester, + Set? requestedIndices, + }) async { + final outgoing = _outgoing[sessionId]; + final fragments = outgoing != null + ? List.from(outgoing.fragments) + : _sessions[sessionId]?.fragments.whereType().toList() ?? + const []; + if (fragments.isEmpty) { + debugPrint( + '⚠️ [ImageProvider] No cached or received session for $sessionId', + ); + return false; + } + return serveCachedSessionFragments( + providerLabel: 'ImageProvider', + sessionId: sessionId, + requester: requester, + fragments: fragments, + maxDirectPayloadHops: maxDirectPayloadHops, + indexOf: (fragment) => fragment.index, + encodeBinary: (fragment) => fragment.encodeBinary(), + sendRawPacket: sendRawPacketCallback, + requestedIndices: requestedIndices, + ); + } + + // ── Persistence ────────────────────────────────────────────────────────── + + Future clearAll() async { + _resetInMemoryState(); + notifyListeners(); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_scopedStorageKey()); + } catch (e) { + debugPrint('❌ [ImageProvider] Failed to clear storage: $e'); + } + } + + Future reloadProfileScopedState() async { + _resetInMemoryState(); + await _restore(); + notifyListeners(); + } + + void _evictExpiredOutgoing() { + final now = DateTime.now(); + _outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl); + } + + Future _persist() async { + try { + _evictExpiredOutgoing(); + final prefs = await SharedPreferences.getInstance(); + final payload = { + 'incoming': _sessions.values + .map( + (s) => { + 'sessionId': s.sessionId, + 'fmtId': s.format.id, + 'total': s.total, + 'width': s.width, + 'height': s.height, + 'fragments': s.fragments + .map( + (f) => f == null ? null : base64.encode(f.encodeBinary()), + ) + .toList(), + }, + ) + .toList(), + 'outgoing': _outgoing.values + .map( + (s) => { + 'sessionId': s.sessionId, + 'cachedAt': s.cachedAt.millisecondsSinceEpoch, + 'envelope': s.envelope.encode(), + 'fragments': s.fragments + .map((f) => base64.encode(f.encodeBinary())) + .toList(), + }, + ) + .toList(), + }; + await prefs.setString(_scopedStorageKey(), jsonEncode(payload)); + } catch (e) { + debugPrint('❌ [ImageProvider] Failed to persist: $e'); + } + } + + Future _restore() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_scopedStorageKey()); + if (raw == null || raw.isEmpty) return; + + final parsed = jsonDecode(raw) as Map; + + for (final item in (parsed['incoming'] as List? ?? [])) { + final map = item as Map; + final sessionId = map['sessionId'] as String?; + final fmtId = map['fmtId'] as int?; + final total = map['total'] as int?; + final width = map['width'] as int? ?? 256; + final height = map['height'] as int? ?? 256; + if (sessionId == null || fmtId == null || total == null || total <= 0) { + continue; + } + final session = ImageSession( + sessionId: sessionId, + format: ImageFormat.fromId(fmtId), + total: total, + width: width, + height: height, + ); + final frags = map['fragments'] as List? ?? []; + for (var i = 0; i < frags.length && i < total; i++) { + final enc = frags[i] as String?; + if (enc == null || enc.isEmpty) continue; + final pkt = ImagePacket.tryParseBinary(base64.decode(enc)); + if (pkt != null && pkt.index < total) { + session.fragments[pkt.index] = pkt; + } + } + _sessions[sessionId] = session; + } + + for (final item in (parsed['outgoing'] as List? ?? [])) { + final map = item as Map; + final sessionId = map['sessionId'] as String?; + final cachedMs = map['cachedAt'] as int?; + final envelopeText = map['envelope'] as String?; + if (sessionId == null || cachedMs == null || envelopeText == null) { + continue; + } + final envelope = ImageEnvelope.tryParse(envelopeText); + if (envelope == null) continue; + final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedMs); + if (DateTime.now().difference(cachedAt) > _outgoingTtl) continue; + + final fragsRaw = map['fragments'] as List? ?? []; + final fragments = []; + for (final enc in fragsRaw) { + final pkt = ImagePacket.tryParseBinary( + base64.decode((enc ?? '') as String), + ); + if (pkt != null) fragments.add(pkt); + } + if (fragments.isNotEmpty) { + _outgoing[sessionId] = _OutgoingSession( + sessionId: sessionId, + fragments: fragments, + envelope: envelope, + cachedAt: cachedAt, + ); + } + } + + if (_sessions.isNotEmpty || _outgoing.isNotEmpty) { + debugPrint( + '📷 [ImageProvider] Restored ${_sessions.length} incoming, ' + '${_outgoing.length} outgoing sessions', + ); + notifyListeners(); + } + } catch (e) { + debugPrint('❌ [ImageProvider] Failed to restore: $e'); + } + } + + void _resetInMemoryState() { + _sessions.clear(); + _outgoing.clear(); + _ignoredIncomingSessions.clear(); + } +} + +class _OutgoingSession { + final String sessionId; + final List fragments; + final ImageEnvelope envelope; + final DateTime cachedAt; + + const _OutgoingSession({ + required this.sessionId, + required this.fragments, + required this.envelope, + required this.cachedAt, + }); +} diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart new file mode 100644 index 0000000..6ab085a --- /dev/null +++ b/lib/providers/map_provider.dart @@ -0,0 +1,782 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/custom_map_config.dart'; +import '../models/location_trail.dart'; +import '../models/map_coordinate_space.dart'; +import '../models/map_drawing.dart'; +import '../models/sar_marker.dart'; +import '../services/profiles_feature_service.dart'; +import '../utils/custom_map_id.dart'; + +class MapProvider with ChangeNotifier { + static const String _customMapConfigKey = 'custom_map_config_v1'; + static const String _customMapModeKey = 'custom_map_mode_v1'; + static const String _customMapsDirName = 'custom_maps'; + + MapProvider() { + unawaited(_loadInitialState()); + } + + final ImagePicker _imagePicker = ImagePicker(); + + LatLng? _targetLocation; + LatLngBounds? _targetBounds; + double? _targetZoom; + bool _shouldAnimate = false; + MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo; + String? _targetMapId; + + LocationTrail? _currentTrail; + bool _isTrailVisible = true; + bool _isTrailRecordingEnabled = true; + final List _trailHistory = []; + + bool _showCadastralOverlay = false; + bool _showForestRoadsOverlay = false; + bool _showHikingTrailsOverlay = false; + bool _showMainRoadsOverlay = false; + bool _showHouseNumbersOverlay = false; + bool _showFireHazardZonesOverlay = false; + bool _showHistoricalFiresOverlay = false; + bool _showFirebreaksOverlay = false; + bool _showKrasFireZonesOverlay = false; + bool _showPlaceNamesOverlay = false; + bool _showMunicipalityBordersOverlay = false; + + bool _hideRepeatersOnMap = false; + + LocationTrail? _importedTrail; + + bool _isSelectingDownloadArea = false; + LatLngBounds? _downloadAreaBounds; + + CustomMapConfig? _customMapConfig; + bool _isUsingCustomMap = false; + + LatLng? get targetLocation => _targetLocation; + LatLngBounds? get targetBounds => _targetBounds; + double? get targetZoom => _targetZoom; + bool get shouldAnimate => _shouldAnimate; + MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace; + String? get targetMapId => _targetMapId; + LocationTrail? get currentTrail => _currentTrail; + bool get isTrailVisible => _isTrailVisible; + bool get isTrailRecordingEnabled => _isTrailRecordingEnabled; + List get trailHistory => List.unmodifiable(_trailHistory); + bool get isTrailActive => _currentTrail?.isActive ?? false; + + bool get showCadastralOverlay => _showCadastralOverlay; + bool get showForestRoadsOverlay => _showForestRoadsOverlay; + bool get showHikingTrailsOverlay => _showHikingTrailsOverlay; + bool get showMainRoadsOverlay => _showMainRoadsOverlay; + bool get showHouseNumbersOverlay => _showHouseNumbersOverlay; + bool get showFireHazardZonesOverlay => _showFireHazardZonesOverlay; + bool get showHistoricalFiresOverlay => _showHistoricalFiresOverlay; + bool get showFirebreaksOverlay => _showFirebreaksOverlay; + bool get showKrasFireZonesOverlay => _showKrasFireZonesOverlay; + bool get showPlaceNamesOverlay => _showPlaceNamesOverlay; + bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay; + + bool get hideRepeatersOnMap => _hideRepeatersOnMap; + + LocationTrail? get importedTrail => _importedTrail; + + bool get isSelectingDownloadArea => _isSelectingDownloadArea; + LatLngBounds? get downloadAreaBounds => _downloadAreaBounds; + + CustomMapConfig? get customMapConfig => _customMapConfig; + bool get hasCustomMap => _customMapConfig != null; + bool get isUsingCustomMap => _isUsingCustomMap && _customMapConfig != null; + bool get shouldHideGpsData => isUsingCustomMap; + + LatLngBounds? get customMapBounds => _customMapConfig?.bounds; + + Future reloadProfileScopedState() async { + await _loadInitialState(); + } + + bool matchesActiveCustomMap(String? mapId) { + return hasCustomMap && + normalizeCustomMapId(_customMapConfig!.mapId) == + normalizeCustomMapId(mapId); + } + + void navigateToLocation({ + required LatLng location, + double zoom = 15.0, + bool animate = true, + }) { + if (_isUsingCustomMap) { + _isUsingCustomMap = false; + unawaited(_saveCustomMapState()); + } + _targetLocation = location; + _targetBounds = null; + _targetZoom = zoom; + _shouldAnimate = animate; + _targetCoordinateSpace = MapCoordinateSpace.geo; + _targetMapId = null; + notifyListeners(); + } + + String? navigateToMapPoint({ + required LatLng point, + required MapCoordinateSpace coordinateSpace, + String? mapId, + double zoom = 15.0, + bool animate = true, + }) { + if (coordinateSpace == MapCoordinateSpace.customMap) { + if (!matchesActiveCustomMap(mapId)) { + return 'Load the matching custom map to view this item.'; + } + if (!_isUsingCustomMap) { + _isUsingCustomMap = true; + unawaited(_saveCustomMapState()); + } + } else if (_isUsingCustomMap) { + _isUsingCustomMap = false; + unawaited(_saveCustomMapState()); + } + + _targetLocation = point; + _targetBounds = null; + _targetZoom = zoom; + _shouldAnimate = animate; + _targetCoordinateSpace = coordinateSpace; + _targetMapId = mapId; + notifyListeners(); + return null; + } + + String? navigateToBounds({ + required LatLngBounds bounds, + required MapCoordinateSpace coordinateSpace, + String? mapId, + bool animate = true, + }) { + if (coordinateSpace == MapCoordinateSpace.customMap) { + if (!matchesActiveCustomMap(mapId)) { + return 'Load the matching custom map to view this item.'; + } + if (!_isUsingCustomMap) { + _isUsingCustomMap = true; + unawaited(_saveCustomMapState()); + } + } else if (_isUsingCustomMap) { + _isUsingCustomMap = false; + unawaited(_saveCustomMapState()); + } + + _targetBounds = bounds; + _targetLocation = bounds.center; + _targetZoom = null; + _shouldAnimate = animate; + _targetCoordinateSpace = coordinateSpace; + _targetMapId = mapId; + notifyListeners(); + return null; + } + + void clearNavigation() { + _targetLocation = null; + _targetBounds = null; + _targetZoom = null; + _shouldAnimate = false; + _targetCoordinateSpace = MapCoordinateSpace.geo; + _targetMapId = null; + } + + String? navigateToDrawing(String drawingId, dynamic drawingProvider) { + final drawing = drawingProvider.getDrawingById(drawingId) as MapDrawing?; + if (drawing == null) { + return 'Drawing not found.'; + } + + if (drawing.coordinateSpace == MapCoordinateSpace.customMap) { + if (!matchesActiveCustomMap(drawing.mapId)) { + return 'Load the matching custom map to view this drawing.'; + } + return navigateToBounds( + bounds: drawing.getBounds(), + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, + ); + } + + final bounds = drawing.getBounds(); + final latDiff = (bounds.north - bounds.south).abs(); + final lonDiff = (bounds.east - bounds.west).abs(); + final maxDiff = latDiff > lonDiff ? latDiff : lonDiff; + + double zoom = 15.0; + if (maxDiff < 0.001) { + zoom = 17.0; + } else if (maxDiff < 0.005) { + zoom = 16.0; + } else if (maxDiff < 0.01) { + zoom = 15.0; + } else if (maxDiff < 0.05) { + zoom = 13.0; + } else if (maxDiff < 0.1) { + zoom = 12.0; + } else { + zoom = 10.0; + } + + navigateToLocation( + location: drawing.getCenter(), + zoom: zoom, + animate: true, + ); + return null; + } + + String? navigateToSarMarker(SarMarker marker) { + if (marker.coordinateSpace == MapCoordinateSpace.customMap) { + return navigateToMapPoint( + point: marker.location, + coordinateSpace: MapCoordinateSpace.customMap, + mapId: marker.mapId, + ); + } + navigateToLocation(location: marker.location, zoom: 15.0, animate: true); + return null; + } + + Future loadCustomMapFromGallery() async { + final picked = await _imagePicker.pickImage(source: ImageSource.gallery); + if (picked == null) { + return false; + } + final bytes = await picked.readAsBytes(); + await setCustomMapImage(bytes: bytes, displayName: picked.name); + return true; + } + + Future replaceCustomMap() async { + await loadCustomMapFromGallery(); + } + + Future setCustomMapImage({ + required Uint8List bytes, + required String displayName, + }) async { + final dimensions = await _decodeImageSize(bytes); + final mapId = normalizeCustomMapId(sha256.convert(bytes).toString())!; + final documentsDir = await getApplicationDocumentsDirectory(); + final dir = Directory('${documentsDir.path}/$_customMapsDirName'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + final extension = _normalizedExtension(displayName); + final nextPath = '${dir.path}/custom_map_$mapId.$extension'; + final nextFile = File(nextPath); + await nextFile.writeAsBytes(bytes, flush: true); + + final previousPath = _customMapConfig?.filePath; + _customMapConfig = CustomMapConfig( + filePath: nextPath, + displayName: displayName, + mapId: mapId, + imageWidth: dimensions.$1, + imageHeight: dimensions.$2, + ); + _isUsingCustomMap = true; + await _saveCustomMapState(); + + if (previousPath != null && previousPath != nextPath) { + unawaited(_deleteFileIfExists(previousPath)); + } + + notifyListeners(); + } + + Future setCustomMapCalibration({ + required LatLng pointA, + required LatLng pointB, + required double metersPerPixel, + }) async { + if (_customMapConfig == null) return; + _customMapConfig = _customMapConfig!.copyWith( + calibrationPointA: pointA, + calibrationPointB: pointB, + metersPerPixel: metersPerPixel, + ); + await _saveCustomMapState(); + notifyListeners(); + } + + Future clearCustomMapCalibration() async { + if (_customMapConfig == null) return; + _customMapConfig = _customMapConfig!.copyWith( + clearMetersPerPixel: true, + clearCalibrationPointA: true, + clearCalibrationPointB: true, + ); + await _saveCustomMapState(); + notifyListeners(); + } + + Future removeCustomMap() async { + final filePath = _customMapConfig?.filePath; + _customMapConfig = null; + _isUsingCustomMap = false; + clearNavigation(); + await _saveCustomMapState(); + if (filePath != null) { + await _deleteFileIfExists(filePath); + } + notifyListeners(); + } + + Future enterCustomMapMode() async { + if (!hasCustomMap || _isUsingCustomMap) return; + _isUsingCustomMap = true; + await _saveCustomMapState(); + notifyListeners(); + } + + Future exitCustomMapMode() async { + if (!_isUsingCustomMap) return; + _isUsingCustomMap = false; + await _saveCustomMapState(); + notifyListeners(); + } + + void updateZoom(double zoom) { + _targetZoom = zoom; + notifyListeners(); + } + + void startTrail() { + if (_currentTrail != null && _currentTrail!.isActive) { + endTrail(); + } + + _currentTrail = LocationTrail( + id: DateTime.now().millisecondsSinceEpoch.toString(), + startTime: DateTime.now(), + ); + _isTrailVisible = true; + notifyListeners(); + } + + void addTrailPoint(LatLng position, {double? accuracy, double? speed}) { + if (!_isTrailRecordingEnabled) { + return; + } + + if (_currentTrail == null || !_currentTrail!.isActive) { + startTrail(); + } + + _currentTrail!.addPoint( + TrailPoint( + position: position, + timestamp: DateTime.now(), + accuracy: accuracy, + speed: speed, + ), + ); + notifyListeners(); + } + + void endTrail() { + if (_currentTrail != null) { + _currentTrail!.isActive = false; + _currentTrail!.endTime = DateTime.now(); + if (_currentTrail!.points.isNotEmpty) { + _trailHistory.add(_currentTrail!); + } + _currentTrail = null; + notifyListeners(); + } + } + + void toggleTrailVisibility() { + _isTrailVisible = !_isTrailVisible; + 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; + notifyListeners(); + } + } + + void clearAllTrails() { + _currentTrail = null; + _trailHistory.clear(); + notifyListeners(); + } + + double get totalTrailDistance { + if (_currentTrail == null) return 0; + return _currentTrail!.totalDistance; + } + + Duration get trailDuration { + if (_currentTrail == null) return Duration.zero; + return _currentTrail!.duration; + } + + Future toggleCadastralOverlay() async { + _showCadastralOverlay = !_showCadastralOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future toggleForestRoadsOverlay() async { + _showForestRoadsOverlay = !_showForestRoadsOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future toggleHikingTrailsOverlay() async { + _showHikingTrailsOverlay = !_showHikingTrailsOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future toggleMainRoadsOverlay() async { + _showMainRoadsOverlay = !_showMainRoadsOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future toggleHouseNumbersOverlay() async { + _showHouseNumbersOverlay = !_showHouseNumbersOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future toggleFireHazardZonesOverlay() async { + _showFireHazardZonesOverlay = !_showFireHazardZonesOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future toggleHistoricalFiresOverlay() async { + _showHistoricalFiresOverlay = !_showHistoricalFiresOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future toggleFirebreaksOverlay() async { + _showFirebreaksOverlay = !_showFirebreaksOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future toggleKrasFireZonesOverlay() async { + _showKrasFireZonesOverlay = !_showKrasFireZonesOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future togglePlaceNamesOverlay() async { + _showPlaceNamesOverlay = !_showPlaceNamesOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future toggleMunicipalityBordersOverlay() async { + _showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + Future loadOverlayState() async { + final prefs = await SharedPreferences.getInstance(); + _showCadastralOverlay = + prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false; + _showForestRoadsOverlay = + prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false; + _showHikingTrailsOverlay = + prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false; + _showMainRoadsOverlay = + prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false; + _showHouseNumbersOverlay = + prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false; + _showFireHazardZonesOverlay = + prefs.getBool(_scopedKey('map_show_fire_hazard_zones_overlay')) ?? + false; + _showHistoricalFiresOverlay = + prefs.getBool(_scopedKey('map_show_historical_fires_overlay')) ?? false; + _showFirebreaksOverlay = + prefs.getBool(_scopedKey('map_show_firebreaks_overlay')) ?? false; + _showKrasFireZonesOverlay = + prefs.getBool(_scopedKey('map_show_kras_fire_zones_overlay')) ?? false; + _showPlaceNamesOverlay = + prefs.getBool(_scopedKey('map_show_place_names_overlay')) ?? false; + _showMunicipalityBordersOverlay = + prefs.getBool(_scopedKey('map_show_municipality_borders_overlay')) ?? + false; + notifyListeners(); + } + + Future _loadInitialState() async { + await Future.wait([ + loadOverlayState(), + loadRepeaterVisibilitySettings(), + _loadCustomMapState(), + _removeDeprecatedSettings(), + ]); + } + + Future _removeDeprecatedSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_scopedKey('map_show_all_contact_trails')); + } + + Future _saveOverlayState() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + _scopedKey('map_show_cadastral_overlay'), + _showCadastralOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_forest_roads_overlay'), + _showForestRoadsOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_hiking_trails_overlay'), + _showHikingTrailsOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_main_roads_overlay'), + _showMainRoadsOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_house_numbers_overlay'), + _showHouseNumbersOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_fire_hazard_zones_overlay'), + _showFireHazardZonesOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_historical_fires_overlay'), + _showHistoricalFiresOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_firebreaks_overlay'), + _showFirebreaksOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_kras_fire_zones_overlay'), + _showKrasFireZonesOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_place_names_overlay'), + _showPlaceNamesOverlay, + ); + await prefs.setBool( + _scopedKey('map_show_municipality_borders_overlay'), + _showMunicipalityBordersOverlay, + ); + } + + Future setHideRepeatersOnMap(bool hide) async { + if (_hideRepeatersOnMap == hide) return; + _hideRepeatersOnMap = hide; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap); + } + + Future loadRepeaterVisibilitySettings() async { + final prefs = await SharedPreferences.getInstance(); + _hideRepeatersOnMap = + prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false; + notifyListeners(); + } + + void setImportedTrail(LocationTrail trail) { + _importedTrail = trail; + notifyListeners(); + } + + void clearImportedTrail() { + _importedTrail = null; + notifyListeners(); + } + + void replaceCurrentTrailWithImport(LocationTrail importedTrail) { + if (_currentTrail != null && _currentTrail!.isActive) { + endTrail(); + } + _currentTrail = importedTrail; + _isTrailVisible = true; + notifyListeners(); + } + + void enterDownloadAreaMode(LatLngBounds initialBounds) { + _isSelectingDownloadArea = true; + _downloadAreaBounds = initialBounds; + notifyListeners(); + } + + void exitDownloadAreaMode() { + _isSelectingDownloadArea = false; + _downloadAreaBounds = null; + notifyListeners(); + } + + void updateDownloadAreaBounds(LatLngBounds bounds) { + _downloadAreaBounds = bounds; + notifyListeners(); + } + + Future _loadCustomMapState() async { + final prefs = await SharedPreferences.getInstance(); + final configJson = prefs.getString(_scopedKey(_customMapConfigKey)); + if (configJson != null && configJson.isNotEmpty) { + final decoded = jsonDecode(configJson); + if (decoded is Map) { + final config = CustomMapConfig.fromJson(decoded); + if (config != null && await File(config.filePath).exists()) { + _customMapConfig = config; + } else { + _customMapConfig = null; + } + } + } else { + _customMapConfig = null; + } + _isUsingCustomMap = + (prefs.getBool(_scopedKey(_customMapModeKey)) ?? false) && + _customMapConfig != null; + notifyListeners(); + } + + Future _saveCustomMapState() async { + final prefs = await SharedPreferences.getInstance(); + if (_customMapConfig == null) { + await prefs.remove(_scopedKey(_customMapConfigKey)); + } else { + await prefs.setString( + _scopedKey(_customMapConfigKey), + jsonEncode(_customMapConfig!.toJson()), + ); + } + await prefs.setBool(_scopedKey(_customMapModeKey), _isUsingCustomMap); + } + + Future<(int, int)> _decodeImageSize(Uint8List bytes) async { + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + return (frame.image.width, frame.image.height); + } + + String _normalizedExtension(String displayName) { + final dotIndex = displayName.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex == displayName.length - 1) { + return 'png'; + } + return displayName.substring(dotIndex + 1).toLowerCase(); + } + + Future _deleteFileIfExists(String path) async { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } + + Map exportWorkspaceJson() { + return { + 'currentTrail': _currentTrail?.toJson(), + 'trailHistory': _trailHistory.map((trail) => trail.toJson()).toList(), + 'importedTrail': _importedTrail?.toJson(), + 'isTrailVisible': _isTrailVisible, + 'isTrailRecordingEnabled': _isTrailRecordingEnabled, + 'showCadastralOverlay': _showCadastralOverlay, + 'showForestRoadsOverlay': _showForestRoadsOverlay, + 'showHikingTrailsOverlay': _showHikingTrailsOverlay, + 'showMainRoadsOverlay': _showMainRoadsOverlay, + 'showHouseNumbersOverlay': _showHouseNumbersOverlay, + 'showFireHazardZonesOverlay': _showFireHazardZonesOverlay, + 'showHistoricalFiresOverlay': _showHistoricalFiresOverlay, + 'showFirebreaksOverlay': _showFirebreaksOverlay, + 'showKrasFireZonesOverlay': _showKrasFireZonesOverlay, + 'showPlaceNamesOverlay': _showPlaceNamesOverlay, + 'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay, + 'hideRepeatersOnMap': _hideRepeatersOnMap, + }; + } + + void applyWorkspaceJson(Map json) { + _currentTrail = json['currentTrail'] is Map + ? LocationTrail.fromJson(json['currentTrail'] as Map) + : null; + _trailHistory + ..clear() + ..addAll( + (json['trailHistory'] as List? ?? const []) + .whereType>() + .map(LocationTrail.fromJson), + ); + _importedTrail = json['importedTrail'] is Map + ? LocationTrail.fromJson(json['importedTrail'] as Map) + : 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 = + json['showHikingTrailsOverlay'] as bool? ?? false; + _showMainRoadsOverlay = json['showMainRoadsOverlay'] as bool? ?? false; + _showHouseNumbersOverlay = + json['showHouseNumbersOverlay'] as bool? ?? false; + _showFireHazardZonesOverlay = + json['showFireHazardZonesOverlay'] as bool? ?? false; + _showHistoricalFiresOverlay = + json['showHistoricalFiresOverlay'] as bool? ?? false; + _showFirebreaksOverlay = json['showFirebreaksOverlay'] as bool? ?? false; + _showKrasFireZonesOverlay = + json['showKrasFireZonesOverlay'] as bool? ?? false; + _showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false; + _showMunicipalityBordersOverlay = + json['showMunicipalityBordersOverlay'] as bool? ?? false; + _hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false; + notifyListeners(); + } + + String _scopedKey(String baseKey) { + return ProfileStorageScope.scopedKey(baseKey); + } +} diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart new file mode 100644 index 0000000..41cad02 --- /dev/null +++ b/lib/providers/messages_provider.dart @@ -0,0 +1,3082 @@ +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'; +import '../models/message_reception_details.dart'; +import '../models/message_transfer_details.dart'; +import '../models/message_route_metadata.dart'; +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'; +import '../utils/voice_message_parser.dart'; +import '../utils/image_message_parser.dart'; +import '../l10n/app_localizations.dart'; +import 'helpers/message_retry_manager.dart'; + +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 _messages = []; + final Map _sarMarkers = {}; + final Set _removedSarMarkerIds = {}; + final MessageStorageService _storageService = MessageStorageService(); + final NotificationService _notificationService = NotificationService(); + bool _isInitialized = false; + bool _isPersisting = false; + bool _persistRequested = false; + bool _suppressReceivedNotifications = false; + AppLocalizations? _localizations; + final Map _messageContactLocations = {}; + final Map _messageReceptionDetails = {}; + final Map _messageTransferDetails = {}; + final Map _messageRouteMetadata = {}; + final Map> + _pendingDeliveredRouteRefreshByContact = + >{}; + String? _storageNamespace; + + // Track pending sent messages by expected ACK/TAG + final Map _pendingSentMessages = {}; + + // Track timeout timers for pending messages + // Key: message ID (not ACK tag, since multiple messages can share same ACK) + final Map _timeoutTimers = {}; + final Map _channelEchoWarningTimers = {}; + final Set _channelEchoWarningMessageIds = {}; + + // Recently completed ACKs are kept briefly to ignore duplicate confirms. + final Map _completedAckHistory = {}; + + // Preserve ACK tags assigned to a message across retries. + final Map> _messageAckHistory = {}; + final Map _ackHistoryLookup = {}; + + // Retry management + final MessageRetryManager _retryManager = MessageRetryManager(); + + // Track which contact each sent message was sent to (for retry logic) + final Map _messageContactMap = {}; + + // Track individual message IDs to grouped message mapping + // Key: individual message ID (e.g., "123_abc"), Value: (groupId, recipientPublicKey) + final Map _groupedMessageMapping = {}; + + // ACK tag → List of (groupId, recipientPublicKey) mapping for grouped messages + // Multiple recipients can share the same ACK tag since they're sent in sequence + // Each ACK delivery removes one recipient from the list + final Map> _ackTagToRecipients = {}; + + // Navigation state for message highlighting/scrolling + String? _targetMessageId; + String? _targetDestinationType; + String? _targetRecipientPublicKeyHex; + + // Helper function to compare Uint8List for equality + bool _listEquals(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + // Callback to connection provider for sending messages (set by AppProvider) + Future Function({ + required Uint8List contactPublicKey, + required String text, + required String messageId, + required Contact contact, + int retryAttempt, + })? + sendMessageCallback; + + Future Function({required Contact contact, required int failureStreak})? + onDirectPathFailedCallback; + + /// Called before the last retry attempt to reset the contact's path, + /// forcing the firmware to use flood mode for the final try. + Future Function(Contact contact)? resetPathBeforeLastRetryCallback; + void Function(String messageId)? onManualRetryPreparedCallback; + Future Function({ + required String messageId, + required Contact contact, + required Message message, + })? + onFinalRouterFallbackCallback; + Future Function({ + required String messageId, + required Contact contact, + required Message message, + })? + onFinalDirectMessageFailureCallback; + void Function({ + required String messageId, + required Contact contact, + required Message message, + required int roundTripTimeMs, + })? + onDirectMessageDeliveredCallback; + + String? Function(Uint8List? publicKey)? resolveContactNameCallback; + String Function(int channelIdx)? resolveChannelNameCallback; + + List get messages => List.unmodifiable(_messages); + + List get contactMessages => + _messages.where((m) => m.isContactMessage).toList(); + + List get channelMessages => + _messages.where((m) => m.isChannelMessage).toList(); + + List get sarMarkerMessages => + _messages.where((m) => m.isSarMarker).toList(); + + List get systemMessages => + _messages.where((m) => m.isSystemMessage).toList(); + + List get sarMarkers => _sarMarkers.values.toList(); + Set get removedSarMarkerIds => Set.unmodifiable(_removedSarMarkerIds); + + List get foundPersonMarkers => + sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList(); + + List get fireMarkers => + sarMarkers.where((m) => m.type == SarMarkerType.fire).toList(); + + List get stagingAreaMarkers => + sarMarkers.where((m) => m.type == SarMarkerType.stagingArea).toList(); + + List get objectMarkers => + sarMarkers.where((m) => m.type == SarMarkerType.object).toList(); + + bool get isInitialized => _isInitialized; + String? get storageNamespace => _storageNamespace; + + String? get targetMessageId => _targetMessageId; + + MessageContactLocation? getMessageContactLocation(String messageId) => + _messageContactLocations[messageId]; + + MessageReceptionDetails? getMessageReceptionDetails(String messageId) => + _messageReceptionDetails[messageId]; + + MessageTransferDetails? getMessageTransferDetails(String messageId) => + _messageTransferDetails[messageId]; + + MessageRouteMetadata? getMessageRouteMetadata(String messageId) => + _messageRouteMetadata[messageId]; + + bool hasChannelSendWarning(String messageId) => + _channelEchoWarningMessageIds.contains(messageId); + + void updateMessageRouteSelection( + String messageId, + PathSelection selection, { + required bool routerFallbackAttempted, + }) { + _messageRouteMetadata[messageId] = MessageRouteMetadata.fromSelection( + selection, + routerFallbackAttempted: routerFallbackAttempted, + ); + + final index = _messages.indexWhere((message) => message.id == messageId); + if (index != -1) { + final nextPathLen = selection.hopCount > 0 + ? selection.hopCount + : _messages[index].pathLen; + _messages[index] = _messages[index].copyWith( + usedFloodFallback: selection.usesFlood, + pathLen: nextPathLen, + pathBytes: selection.hasDirectPath + ? Uint8List.fromList(selection.pathBytes) + : Uint8List(0), + ); + } + + _persistMessages(); + 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; + } + + /// Navigate to a specific message (scroll and highlight) + void navigateToMessage(String messageId) { + _targetMessageId = messageId; + notifyListeners(); + } + + String? get targetDestinationType => _targetDestinationType; + String? get targetRecipientPublicKeyHex => _targetRecipientPublicKeyHex; + + void navigateToDestination(String type, {String? recipientPublicKeyHex}) { + _targetDestinationType = type; + _targetRecipientPublicKeyHex = recipientPublicKeyHex; + notifyListeners(); + } + + /// Clear message navigation state + void clearMessageNavigation() { + _targetMessageId = null; + } + + void clearDestinationNavigation() { + _targetDestinationType = null; + _targetRecipientPublicKeyHex = null; + } + + /// Get count of unread messages (excluding sent messages and system messages) + int get unreadCount => _messages + .where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage) + .length; + + int getMessageCountForDestination(Contact contact) => _messages + .where((message) => _isMessageForDestination(message, contact)) + .length; + + DateTime? getLastActivityForDestination(Contact contact) { + DateTime? latest; + + for (final message in _messages) { + if (!_isMessageForDestination(message, contact)) { + continue; + } + + if (latest == null || message.sentAt.isAfter(latest)) { + latest = message.sentAt; + } + } + + return latest; + } + + int getUnreadCountForDestination(Contact contact) => _messages + .where( + (message) => + _isMessageForDestination(message, contact) && + !message.isRead && + !message.isSentMessage && + !message.isSystemMessage, + ) + .length; + + int getUnreadCountForChannel(int channelIdx) => _messages + .where( + (message) => + message.isChannelMessage && + (message.channelIdx ?? 0) == channelIdx && + !message.isRead && + !message.isSentMessage && + !message.isSystemMessage, + ) + .length; + + bool _isMessageForDestination(Message message, Contact contact) { + if (message.isSystemMessage) return false; + + if (contact.isChannel) { + final channelIdx = contact.publicKey.length > 1 + ? contact.publicKey[1] + : 0; + return message.isChannelMessage && + (message.channelIdx ?? 0) == channelIdx; + } + + if (!message.isContactMessage || contact.publicKey.length < 6) { + return false; + } + + final selectedPrefix = contact.publicKey.sublist(0, 6); + + if (message.recipientPublicKey != null && + message.recipientPublicKey!.length >= 6 && + _matchesPrefix(message.recipientPublicKey!, selectedPrefix)) { + return true; + } + + if (message.senderPublicKeyPrefix != null && + message.senderPublicKeyPrefix!.length >= 6 && + _matchesPrefix(message.senderPublicKeyPrefix!, selectedPrefix)) { + return true; + } + + return false; + } + + bool _matchesPrefix(Uint8List key, Uint8List prefix) { + for (var i = 0; i < 6; i++) { + if (key[i] != prefix[i]) return false; + } + return true; + } + + /// Initialize and load persisted messages + Future initialize() async { + if (_isInitialized) return; + + await _loadFromStorage(); + } + + Future reloadFromStorage({String? namespace}) async { + _storageNamespace = namespace; + await _loadFromStorage(force: true); + } + + Future persistNow() async { + await _storageService.saveMessages( + _messages, + messageContactLocations: _messageContactLocations, + messageReceptionDetails: _messageReceptionDetails, + messageTransferDetails: _messageTransferDetails, + messageRouteMetadata: _messageRouteMetadata, + namespace: _storageNamespace, + ); + await _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ); + } + + Future cloneCurrentStorageTo(String? namespace) async { + await _storageService.saveMessages( + _messages, + messageContactLocations: _messageContactLocations, + messageReceptionDetails: _messageReceptionDetails, + messageTransferDetails: _messageTransferDetails, + messageRouteMetadata: _messageRouteMetadata, + namespace: namespace, + ); + await _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: namespace, + ); + } + + Future _loadFromStorage({bool force = false}) async { + if (_isInitialized && !force) return; + + try { + _cancelAllTimers(); + _resetInMemoryState(); + debugPrint('📦 [MessagesProvider] Loading persisted messages...'); + final storedMessages = await _storageService.loadMessages( + namespace: _storageNamespace, + ); + final storedContactLocations = await _storageService + .loadMessageContactLocations(namespace: _storageNamespace); + final storedReceptionDetails = await _storageService + .loadMessageReceptionDetails(namespace: _storageNamespace); + final storedTransferDetails = await _storageService + .loadMessageTransferDetails(namespace: _storageNamespace); + final storedRouteMetadata = await _storageService + .loadMessageRouteMetadata(namespace: _storageNamespace); + final storedRemovedSarMarkerIds = await _storageService + .loadRemovedSarMarkerIds(namespace: _storageNamespace); + _removedSarMarkerIds + ..clear() + ..addAll(storedRemovedSarMarkerIds); + + // 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 + var enhancedMessage = SarMessageParser.enhanceMessage(message); + + // Check if it's a drawing message (D:...) and not already marked + // This handles cases where messages were stored before drawing detection + if (DrawingMessageParser.isDrawingMessage(enhancedMessage.text) && + !enhancedMessage.isDrawing) { + debugPrint( + '🎨 [MessagesProvider] Detected drawing message during initialization: ${enhancedMessage.id}', + ); + // Parse the drawing to get its ID + final drawing = DrawingMessageParser.parseDrawingMessage( + enhancedMessage.text, + senderName: enhancedMessage.senderName, + messageId: enhancedMessage.id, + ); + + // Mark message as drawing and link to drawing ID + enhancedMessage = enhancedMessage.copyWith( + isDrawing: true, + drawingId: drawing?.id, + ); + debugPrint( + ' Drawing ID: ${enhancedMessage.drawingId}, isDrawing: ${enhancedMessage.isDrawing}', + ); + } + + // Check if it's a voice envelope/message and not already marked. + if (!enhancedMessage.isVoice) { + final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text); + if (envelope != null) { + enhancedMessage = enhancedMessage.copyWith( + isVoice: true, + voiceId: envelope.sessionId, + ); + } + } + + 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) { + final marker = enhancedMessage.toSarMarker(); + if (marker != null && !_removedSarMarkerIds.contains(marker.id)) { + _sarMarkers[marker.id] = marker; + } + } + } + + _isInitialized = true; + debugPrint( + '✅ [MessagesProvider] Loaded ${storedMessages.length} persisted messages', + ); + notifyListeners(); + } catch (e) { + debugPrint('❌ [MessagesProvider] Error initializing: $e'); + _isInitialized = true; // Mark as initialized even on error + } + } + + /// Sync drawing messages with DrawingProvider + /// This restores drawings that may be missing from DrawingProvider storage + /// Should be called after both providers are initialized + void syncDrawingsWithProvider(dynamic drawingProvider) { + debugPrint( + '🔄 [MessagesProvider] Syncing drawings with DrawingProvider...', + ); + int restoredCount = 0; + + for (final message in _messages) { + if (!message.isDrawing || message.drawingId == null) continue; + + // Check if drawing exists in DrawingProvider + final existingDrawing = drawingProvider.getDrawingById( + message.drawingId!, + ); + if (existingDrawing != null) { + continue; // Drawing already exists + } + + // Drawing is missing, reconstruct from message text + debugPrint( + '🔧 [MessagesProvider] Restoring missing drawing: ${message.drawingId}', + ); + final drawing = DrawingMessageParser.parseDrawingMessage( + message.text, + senderName: message.senderName, + messageId: message.id, + ); + + if (drawing == null) { + debugPrint( + '⚠️ [MessagesProvider] Failed to parse drawing from message ${message.id}', + ); + continue; + } + + // The parsed drawing has a new generated ID, but we need to use the original ID + // Create a copy with the correct ID from the message + final restoredDrawing = _createDrawingWithId(drawing, message.drawingId!); + + if (restoredDrawing != null) { + drawingProvider.addReceivedDrawing(restoredDrawing); + restoredCount++; + debugPrint( + '✅ [MessagesProvider] Restored drawing ${message.drawingId}', + ); + } + } + + debugPrint( + '✅ [MessagesProvider] Sync complete: restored $restoredCount drawings', + ); + } + + /// Create a copy of a drawing with a specific ID + dynamic _createDrawingWithId(dynamic drawing, String targetId) { + if (drawing is LineDrawing) { + return LineDrawing( + id: targetId, + color: drawing.color, + createdAt: drawing.createdAt, + points: drawing.points, + senderName: drawing.senderName, + isReceived: drawing.isReceived, + messageId: drawing.messageId, + isShared: drawing.isShared, + ); + } else if (drawing is RectangleDrawing) { + return RectangleDrawing( + id: targetId, + color: drawing.color, + createdAt: drawing.createdAt, + topLeft: drawing.topLeft, + bottomRight: drawing.bottomRight, + senderName: drawing.senderName, + isReceived: drawing.isReceived, + messageId: drawing.messageId, + isShared: drawing.isShared, + ); + } + return null; + } + + /// Add a message + /// If [contactLookup] function is provided, it will be used to match channel + /// message senders with known contacts by name + void addMessage( + Message message, { + String Function(String name)? contactLookup, + MessageContactLocation? contactLocationSnapshot, + MessageReceptionDetails? receptionDetailsSnapshot, + }) { + // Always enhance message with SAR parser to detect SAR markers + var enhancedMessage = SarMessageParser.enhanceMessage(message); + + // Check if it's a drawing message (D:...) and not already marked + // Don't overwrite if already set by the sender (preserves correct drawing ID) + if (DrawingMessageParser.isDrawingMessage(enhancedMessage.text) && + !enhancedMessage.isDrawing) { + // Parse the drawing to get its ID + final drawing = DrawingMessageParser.parseDrawingMessage( + enhancedMessage.text, + senderName: enhancedMessage.senderName, + messageId: enhancedMessage.id, + ); + + // Mark message as drawing and link to drawing ID + enhancedMessage = enhancedMessage.copyWith( + isDrawing: true, + drawingId: drawing?.id, + ); + } + + // Check if it's a voice message (VE1:/V:) and not already marked. + if (!enhancedMessage.isVoice) { + final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text); + if (envelope != null) { + enhancedMessage = enhancedMessage.copyWith( + isVoice: true, + voiceId: envelope.sessionId, + ); + } + } + enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage); + + // For channel messages with sender name, try to link with contact + Message finalMessage = enhancedMessage; + if (enhancedMessage.isChannelMessage && + enhancedMessage.senderName != null && + contactLookup != null) { + // Look up contact public key by name + final publicKeyHex = contactLookup(enhancedMessage.senderName!); + if (publicKeyHex.isNotEmpty) { + // Convert hex string to bytes (first 6 bytes) + final publicKeyBytes = []; + for (int i = 0; i < 12 && i < publicKeyHex.length; i += 2) { + final byteString = publicKeyHex.substring(i, i + 2); + publicKeyBytes.add(int.parse(byteString, radix: 16)); + } + + if (publicKeyBytes.length == 6) { + // Add public key prefix to message + finalMessage = enhancedMessage.copyWith( + senderPublicKeyPrefix: Uint8List.fromList(publicKeyBytes), + ); + } + } + } + + final incomingPathBytes = receptionDetailsSnapshot?.pathBytes; + if (incomingPathBytes != null && + incomingPathBytes.isNotEmpty && + finalMessage.pathBytes == null) { + finalMessage = finalMessage.copyWith( + pathBytes: Uint8List.fromList(incomingPathBytes), + ); + } + + // Debug: Check if message is SAR + if (message.text.startsWith('S:')) { + debugPrint( + '🔍 [MessagesProvider] Processing SAR message: ${message.text}', + ); + debugPrint(' isSarMarker: ${finalMessage.isSarMarker}'); + debugPrint(' sarMarkerType: ${finalMessage.sarMarkerType}'); + } + + // Check for duplicates before adding + // Messages can arrive multiple times due to: + // - Mesh network retransmissions + // - Multiple paths in the network + // - Syncing messages from device queue + final duplicateIndex = _findDuplicateMessageIndex(finalMessage); + if (duplicateIndex != -1) { + debugPrint( + '⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}', + ); + debugPrint( + ' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...', + ); + final existingId = _messages[duplicateIndex].id; + if (finalMessage.isChannelMessage && + !finalMessage.isSentMessage && + _messages[duplicateIndex].isSentMessage) { + _clearChannelSendWarning(existingId); + } + if (contactLocationSnapshot != null) { + _messageContactLocations[existingId] = contactLocationSnapshot; + } + _messageReceptionDetails[existingId] = + MessageReceptionDetails.mergeDuplicate( + existing: _messageReceptionDetails[existingId], + incoming: receptionDetailsSnapshot, + ); + if (receptionDetailsSnapshot != null) { + final existingMessage = _messages[duplicateIndex]; + final duplicatePathBytes = receptionDetailsSnapshot.pathBytes; + if (existingMessage.pathBytes == null && + duplicatePathBytes != null && + duplicatePathBytes.isNotEmpty) { + _messages[duplicateIndex] = existingMessage.copyWith( + pathBytes: Uint8List.fromList(duplicatePathBytes), + ); + } + } + _persistMessages(); + 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; + } + if (receptionDetailsSnapshot != null) { + _messageReceptionDetails[finalMessage.id] = receptionDetailsSnapshot; + } + + // If it's a SAR marker message, extract and store the marker + if (finalMessage.isSarMarker) { + final marker = finalMessage.toSarMarker(); + if (marker != null && !_removedSarMarkerIds.contains(marker.id)) { + _sarMarkers[marker.id] = marker; + + // Trigger urgent notification for received SAR messages (not sent by user) + if (!finalMessage.isSentMessage && !_suppressReceivedNotifications) { + _triggerSarNotification(finalMessage, marker); + } + } + } else if (!finalMessage.isSentMessage && + !finalMessage.isSystemMessage && + !_suppressReceivedNotifications) { + // Trigger notification for regular messages (not SAR, not sent by user, not system) + _triggerMessageNotification(finalMessage); + } + + // Persist to storage asynchronously + _persistMessages(); + + notifyListeners(); + } + + Future withReceivedNotificationsSuppressed( + Future Function() action, + ) async { + final previous = _suppressReceivedNotifications; + _suppressReceivedNotifications = true; + try { + return await action(); + } finally { + _suppressReceivedNotifications = previous; + } + } + + /// Check if a message is a duplicate + /// + /// Messages are considered duplicates if they have: + /// 1. Same sender public key prefix (for contact messages) + /// 2. Same channel index (for channel messages) + /// 3. Same text content + /// + /// Note: Sent messages (isSentMessage=true) are NEVER duplicates + /// because they can be retried with different message IDs + int _findDuplicateMessageIndex(Message message) { + // Sent messages (our own messages) should never be considered duplicates + // They can be retried multiple times with different IDs + if (message.isSentMessage) { + 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 (existing.isSentMessage || + !_matchesExactDuplicateScope(existing, message) || + _normalizedDuplicateText(existing.text) != normalizedIncomingText) { + continue; + } + if (message.isChannelMessage) { + debugPrint( + '🔁 [MessagesProvider] Channel duplicate match: ' + 'incoming(id=${message.id}, senderName=${message.senderName ?? "-"}, senderKey=${message.senderKeyShort ?? "-"}, pathLen=${message.pathLen}, ts=${message.senderTimestamp}) ' + 'existing(id=${existing.id}, sent=${existing.isSentMessage}, senderName=${existing.senderName ?? "-"}, senderKey=${existing.senderKeyShort ?? "-"}, pathLen=${existing.pathLen}, ts=${existing.senderTimestamp})', + ); + } + return index; + } + + return -1; + } + + 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) { + if (!_isSameConversation(existing, message)) { + return false; + } + return existing.senderTimestamp == message.senderTimestamp && + _matchesDuplicateSenderIdentity(existing, message); + } + + if (message.isChannelMessage) { + if (existing.channelIdx != message.channelIdx) { + return false; + } + + if (existing.senderTimestamp == message.senderTimestamp) { + return true; + } + return false; + } + + // System messages and other types: never deduplicate by scope alone. + 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 messages) { + int addedCount = 0; + int duplicateCount = 0; + + for (final message in messages) { + // Always enhance message with SAR parser to detect SAR markers + var enhancedMessage = SarMessageParser.enhanceMessage(message); + + // Check if it's a drawing message (D:...) and not already marked + if (DrawingMessageParser.isDrawingMessage(enhancedMessage.text) && + !enhancedMessage.isDrawing) { + final drawing = DrawingMessageParser.parseDrawingMessage( + enhancedMessage.text, + senderName: enhancedMessage.senderName, + messageId: enhancedMessage.id, + ); + enhancedMessage = enhancedMessage.copyWith( + isDrawing: true, + drawingId: drawing?.id, + ); + } + + // Check if it's a voice message and not already marked + if (!enhancedMessage.isVoice) { + final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text); + if (envelope != null) { + enhancedMessage = enhancedMessage.copyWith( + isVoice: true, + voiceId: envelope.sessionId, + ); + } + } + enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage); + + // Check for duplicates + final duplicateIndex = _findDuplicateMessageIndex(enhancedMessage); + if (duplicateIndex != -1) { + final existingId = _messages[duplicateIndex].id; + _messageReceptionDetails[existingId] = + MessageReceptionDetails.mergeDuplicate( + existing: _messageReceptionDetails[existingId], + ); + duplicateCount++; + continue; // Skip duplicate + } + + _messages.add(enhancedMessage); + addedCount++; + + if (enhancedMessage.isSarMarker) { + final marker = enhancedMessage.toSarMarker(); + if (marker != null && !_removedSarMarkerIds.contains(marker.id)) { + _sarMarkers[marker.id] = marker; + } + } + } + + debugPrint( + '📥 [MessagesProvider] Added $addedCount messages, skipped $duplicateCount duplicates', + ); + + // Persist to storage asynchronously + _persistMessages(); + + notifyListeners(); + } + + String? _normalizeSenderName(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + var normalized = trimmed.toLowerCase(); + if (normalized.startsWith('meshcore-')) { + normalized = normalized.substring('meshcore-'.length).trim(); + } + + return normalized.isEmpty ? null : normalized; + } + + Message _resolveSenderNameIfNeeded(Message message) { + if (message.senderName != null || message.senderPublicKeyPrefix == null) { + return message; + } + + final resolvedSenderName = resolveContactNameCallback?.call( + message.senderPublicKeyPrefix, + ); + if (resolvedSenderName == null || resolvedSenderName.trim().isEmpty) { + return message; + } + + return message.copyWith(senderName: resolvedSenderName.trim()); + } + + String? _normalizedResolvedSenderName(Message message) { + final directName = _normalizeSenderName(message.senderName); + if (directName != null) { + return directName; + } + + if (message.senderPublicKeyPrefix == null) { + return null; + } + + final resolved = resolveContactNameCallback?.call( + message.senderPublicKeyPrefix, + ); + return _normalizeSenderName(resolved); + } + + String _normalizedDuplicateText(String value) { + return value.replaceAll('\r\n', '\n').trim(); + } + + void _scheduleChannelEchoWarning(String messageId) { + _channelEchoWarningTimers[messageId]?.cancel(); + _channelEchoWarningMessageIds.remove(messageId); + _channelEchoWarningTimers[messageId] = Timer(_channelEchoWarningDelay, () { + final index = _messages.indexWhere((m) => m.id == messageId); + _channelEchoWarningTimers.remove(messageId); + if (index == -1) { + return; + } + + final message = _messages[index]; + if (!message.isChannelMessage || + !message.isSentMessage || + message.deliveryStatus != MessageDeliveryStatus.sent || + message.echoCount > 0) { + return; + } + + _channelEchoWarningMessageIds.add(messageId); + notifyListeners(); + }); + } + + void _clearChannelSendWarning(String messageId) { + _channelEchoWarningTimers[messageId]?.cancel(); + _channelEchoWarningTimers.remove(messageId); + _channelEchoWarningMessageIds.remove(messageId); + } + + /// Trigger urgent notification for SAR marker + Future _triggerSarNotification( + Message message, + SarMarker marker, + ) async { + try { + // Format coordinates + final coords = + '${marker.location.latitude.toStringAsFixed(5)}, ${marker.location.longitude.toStringAsFixed(5)}'; + + // Get sender name from message + final senderName = + message.senderName ?? message.senderKeyShort ?? 'Unknown'; + + debugPrint( + '🔔 [MessagesProvider] Triggering SAR notification for ${marker.type.displayName}', + ); + debugPrint(' Sender: $senderName'); + debugPrint(' Coordinates: $coords'); + + await _notificationService.showSarNotification( + type: marker.type, + senderName: senderName, + coordinates: coords, + notes: marker.notes, + localizations: _localizations, + ); + } catch (e) { + debugPrint('❌ [MessagesProvider] Error triggering SAR notification: $e'); + } + } + + /// Trigger notification for regular message + Future _triggerMessageNotification(Message message) async { + try { + final senderName = _resolveParticipantName( + publicKey: message.senderPublicKeyPrefix, + fallback: message.senderName ?? message.senderKeyShort, + ); + final isChannelMessage = message.isChannelMessage; + final channelName = isChannelMessage + ? _resolveChannelName(message.channelIdx) + : null; + final messageText = _buildNotificationMessageText( + message, + senderName: senderName, + isChannelMessage: isChannelMessage, + channelName: channelName, + ); + + debugPrint('🔔 [MessagesProvider] Triggering message notification'); + debugPrint(' Sender: $senderName'); + debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}'); + debugPrint( + ' Message: ${messageText.substring(0, messageText.length > 50 ? 50 : messageText.length)}...', + ); + + await _notificationService.showMessageNotification( + senderName: senderName, + messageText: messageText, + isChannelMessage: isChannelMessage, + channelName: channelName, + localizations: _localizations, + ); + } catch (e) { + debugPrint( + '❌ [MessagesProvider] Error triggering message notification: $e', + ); + } + } + + String _resolveParticipantName({ + required Uint8List? publicKey, + String? fallback, + }) { + final resolved = resolveContactNameCallback?.call(publicKey)?.trim(); + if (resolved != null && resolved.isNotEmpty) { + return resolved; + } + final normalizedFallback = fallback?.trim(); + if (normalizedFallback != null && normalizedFallback.isNotEmpty) { + return normalizedFallback; + } + return 'Unknown'; + } + + String _resolveChannelName(int? channelIdx) { + final idx = channelIdx ?? 0; + final resolved = resolveChannelNameCallback?.call(idx).trim(); + if (resolved != null && resolved.isNotEmpty) { + if (idx == 0 && resolved == 'Public') { + return _localizations?.publicChannel ?? resolved; + } + return resolved; + } + if (idx == 0) { + return _localizations?.publicChannel ?? 'Public'; + } + return _localizations != null + ? '${_localizations!.channel} $idx' + : 'Channel $idx'; + } + + String _buildNotificationMessageText( + Message message, { + required String senderName, + required bool isChannelMessage, + String? channelName, + }) { + final voiceEnvelope = VoiceEnvelope.tryParseText(message.text); + if (voiceEnvelope != null) { + final seconds = (voiceEnvelope.durationMs / 1000).ceil(); + final summary = + 'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets'; + return summary; + } + + final imageEnvelope = ImageEnvelope.tryParse(message.text); + if (imageEnvelope != null) { + final summary = + 'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}'; + return summary; + } + + if (!isChannelMessage && message.recipientPublicKey != null) { + final recipientName = _resolveParticipantName( + publicKey: message.recipientPublicKey, + fallback: null, + ); + if (recipientName != 'Unknown') { + return 'To: $recipientName\n${message.text}'; + } + } + + return message.text; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + final kib = bytes / 1024; + if (kib < 1024) return '${kib.toStringAsFixed(kib >= 10 ? 0 : 1)} KB'; + final mib = kib / 1024; + return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB'; + } + + /// Persist messages to storage (async, non-blocking, coalescing). + /// + /// Multiple rapid calls are coalesced into a single write to avoid + /// overlapping serialization and redundant SharedPreferences writes. + Future _persistMessages() async { + _persistRequested = true; + if (_isPersisting) { + return; // A write is in flight; it will pick up our changes. + } + _isPersisting = true; + try { + while (_persistRequested) { + _persistRequested = false; + await _storageService.saveMessages( + _messages, + messageContactLocations: _messageContactLocations, + messageReceptionDetails: _messageReceptionDetails, + messageTransferDetails: _messageTransferDetails, + messageRouteMetadata: _messageRouteMetadata, + namespace: _storageNamespace, + ); + } + } catch (e) { + debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); + } finally { + _isPersisting = false; + } + } + + /// Get messages for a specific contact + List getMessagesForContact(String senderKeyShort) { + return _messages + .where( + (m) => + m.isContactMessage && + m.senderKeyShort != null && + m.senderKeyShort!.startsWith(senderKeyShort), + ) + .toList(); + } + + /// Get messages for a specific channel + List getMessagesForChannel(int channelIdx) { + return _messages + .where((m) => m.isChannelMessage && m.channelIdx == channelIdx) + .toList(); + } + + /// Remove all messages associated with a specific channel. + void clearChannelMessages(int channelIdx) { + final messageIdsToRemove = _messages + .where( + (message) => + message.isChannelMessage && message.channelIdx == channelIdx, + ) + .map((message) => message.id) + .toList(); + + if (messageIdsToRemove.isEmpty) { + return; + } + + _messages.removeWhere( + (message) => message.isChannelMessage && message.channelIdx == channelIdx, + ); + + for (final messageId in messageIdsToRemove) { + _timeoutTimers[messageId]?.cancel(); + _timeoutTimers.remove(messageId); + _clearChannelSendWarning(messageId); + _messageContactMap.remove(messageId); + _groupedMessageMapping.remove(messageId); + _messageContactLocations.remove(messageId); + _messageReceptionDetails.remove(messageId); + _messageTransferDetails.remove(messageId); + _messageRouteMetadata.remove(messageId); + } + + _pendingSentMessages.removeWhere( + (_, message) => + message.isChannelMessage && message.channelIdx == channelIdx, + ); + _messageAckHistory.removeWhere( + (messageId, _) => messageIdsToRemove.contains(messageId), + ); + _ackHistoryLookup.removeWhere( + (_, value) => messageIdsToRemove.contains(value.$1), + ); + + debugPrint( + '🗑️ [MessagesProvider] Cleared ${messageIdsToRemove.length} message(s) for channel $channelIdx', + ); + + _persistMessages(); + notifyListeners(); + } + + /// Get recent messages (last N messages) + List getRecentMessages({int count = 50}) { + final sorted = List.from(_messages) + ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + return sorted.take(count).toList(); + } + + List buildDisplayMessages(Iterable messages) { + final entries = []; + + for (final message in messages) { + final occurrenceCount = _messageOccurrenceCount(message); + final existingIndex = entries.indexWhere( + (entry) => _shouldCollapseDisplayMessage(entry.message, message), + ); + + if (existingIndex == -1) { + entries.add((message: message, occurrenceCount: occurrenceCount)); + continue; + } + + final existingEntry = entries[existingIndex]; + entries[existingIndex] = ( + message: existingEntry.message, + occurrenceCount: existingEntry.occurrenceCount + occurrenceCount, + ); + } + + 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; + + /// Get messages from last N hours + List getMessagesSince(Duration duration) { + final cutoff = DateTime.now().subtract(duration); + return _messages.where((m) => m.sentAt.isAfter(cutoff)).toList(); + } + + /// Search messages by text + List searchMessages(String query) { + if (query.isEmpty) return []; + final lowerQuery = query.toLowerCase(); + return _messages + .where((m) => m.text.toLowerCase().contains(lowerQuery)) + .toList(); + } + + /// Get SAR marker by ID + SarMarker? getSarMarker(String id) { + return _sarMarkers[id]; + } + + Message? getMessageById(String id) { + final index = _messages.indexWhere((message) => message.id == id); + if (index == -1) return null; + return _messages[index]; + } + + /// Get recent SAR markers (within last hour) + List getRecentSarMarkers() { + return sarMarkers.where((m) => m.isRecent).toList(); + } + + /// Remove a SAR marker + Future removeSarMarker(String id) async { + _sarMarkers.remove(id); + _removedSarMarkerIds.add(id); + await _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ); + notifyListeners(); + } + + Future removeSarMarkerPermanently(String id) async { + final hasBackingMessage = _messages.any((message) => message.id == id); + if (hasBackingMessage) { + deleteMessage(id); + return; + } + + await removeSarMarker(id); + } + + /// Mark all messages as read + void markAllAsRead() { + bool hasChanges = false; + for (int i = 0; i < _messages.length; i++) { + if (!_messages[i].isRead && + !_messages[i].isSentMessage && + !_messages[i].isSystemMessage) { + _messages[i] = _messages[i].copyWith(isRead: true); + hasChanges = true; + } + } + if (hasChanges) { + _persistMessages(); + notifyListeners(); + } + } + + /// Mark unread messages for a specific destination as read. + void markDestinationAsRead({ + required String destinationType, + Contact? contact, + }) { + if (destinationType == 'all') { + markAllAsRead(); + return; + } + + bool hasChanges = false; + for (int i = 0; i < _messages.length; i++) { + final message = _messages[i]; + if (message.isRead || message.isSentMessage || message.isSystemMessage) { + continue; + } + + final matchesDestination = switch (destinationType) { + 'channel' => _isChannelMessageForContact(message, contact), + 'contact' || + 'room' => contact != null && _isMessageForDestination(message, contact), + _ => false, + }; + + if (!matchesDestination) { + continue; + } + + _messages[i] = message.copyWith(isRead: true); + hasChanges = true; + } + + if (hasChanges) { + _persistMessages(); + notifyListeners(); + } + } + + bool _isChannelMessageForContact(Message message, Contact? contact) { + if (!message.isChannelMessage) { + return false; + } + + final selectedChannelIdx = contact != null && contact.publicKey.length > 1 + ? contact.publicKey[1] + : 0; + return (message.channelIdx ?? 0) == selectedChannelIdx; + } + + /// Mark a specific message as read + void markAsRead(String messageId) { + final index = _messages.indexWhere((m) => m.id == messageId); + if (index != -1 && !_messages[index].isRead) { + _messages[index] = _messages[index].copyWith(isRead: true); + _persistMessages(); + notifyListeners(); + } + } + + /// Delete a specific message by ID + void deleteMessage(String messageId) { + final index = _messages.indexWhere((m) => m.id == messageId); + if (index != -1) { + final message = _messages[index]; + + // If it's a SAR marker message, also remove the marker + if (message.isSarMarker) { + final marker = message.toSarMarker(); + if (marker != null) { + _sarMarkers.remove(marker.id); + _removedSarMarkerIds.add(marker.id); + } + } + + // Remove from messages list + _messages.removeAt(index); + + // Cancel timeout timer if it exists + _timeoutTimers[message.id]?.cancel(); + _timeoutTimers.remove(message.id); + _clearChannelSendWarning(message.id); + if (message.expectedAckTag != null) { + _pendingSentMessages.remove(message.expectedAckTag); + } + _messageContactMap.remove(messageId); + _groupedMessageMapping.remove(messageId); + _messageContactLocations.remove(messageId); + _messageReceptionDetails.remove(messageId); + _messageTransferDetails.remove(messageId); + _messageRouteMetadata.remove(messageId); + + debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); + + _persistMessages(); + unawaited( + _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ), + ); + notifyListeners(); + } + } + + /// Delete a drawing message and its linked drawing + void deleteDrawingMessage(String messageId, dynamic drawingProvider) { + final index = _messages.indexWhere((m) => m.id == messageId); + if (index == -1) return; + final message = _messages[index]; + + // If the message has a linked drawing, remove it + if (message.drawingId != null && drawingProvider != null) { + // Remove the drawing (DrawingProvider will handle removing this message) + drawingProvider.removeDrawing(message.drawingId!); + } else { + // No linked drawing, just delete the message + deleteMessage(messageId); + } + } + + /// Clear all messages + void clearMessages() { + _cancelAllTimers(); + _resetInMemoryState(); + _retryManager.clearAll(); + _persistMessages(); + unawaited( + _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ), + ); + notifyListeners(); + } + + /// Clear all SAR markers + Future clearSarMarkers() async { + _removedSarMarkerIds.addAll(_sarMarkers.keys); + _sarMarkers.clear(); + await _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ); + notifyListeners(); + } + + /// Clear all data + void clearAll() { + _cancelAllTimers(); + _resetInMemoryState(); + _retryManager.clearAll(); + _persistMessages(); + unawaited( + _storageService.saveRemovedSarMarkerIds( + _removedSarMarkerIds, + namespace: _storageNamespace, + ), + ); + notifyListeners(); + } + + void _resetInMemoryState() { + _messages.clear(); + _sarMarkers.clear(); + _removedSarMarkerIds.clear(); + _messageContactLocations.clear(); + _messageReceptionDetails.clear(); + _messageTransferDetails.clear(); + _messageRouteMetadata.clear(); + _pendingSentMessages.clear(); + _messageContactMap.clear(); + _groupedMessageMapping.clear(); + _ackTagToRecipients.clear(); + _messageAckHistory.clear(); + _ackHistoryLookup.clear(); + _completedAckHistory.clear(); + } + + int transferCountForSession({ + String? voiceSessionId, + String? imageSessionId, + }) { + final messageId = _findMessageIdByMediaSession( + voiceSessionId: voiceSessionId, + imageSessionId: imageSessionId, + ); + if (messageId == null) return 0; + return _messageTransferDetails[messageId]?.totalTransfers ?? 0; + } + + void recordMediaTransfer({ + required String sessionId, + required String mediaType, + required String requesterKey6, + String? requesterName, + }) { + final messageId = _findMessageIdByMediaSession( + voiceSessionId: mediaType == 'voice' ? sessionId : null, + imageSessionId: mediaType == 'image' ? sessionId : null, + ); + if (messageId == null) { + debugPrint( + '⚠️ [MessagesProvider] No message found for $mediaType session $sessionId', + ); + return; + } + + final current = + _messageTransferDetails[messageId] ?? + const MessageTransferDetails.empty(); + _messageTransferDetails[messageId] = current.registerTransfer( + requesterKey6: requesterKey6, + requesterName: requesterName, + ); + _persistMessages(); + notifyListeners(); + } + + String? _findMessageIdByMediaSession({ + String? voiceSessionId, + String? imageSessionId, + }) { + for (final message in _messages.reversed) { + if (voiceSessionId != null && message.voiceId == voiceSessionId) { + return message.id; + } + if (imageSessionId != null) { + final envelope = ImageEnvelope.tryParse(message.text); + if (envelope != null && envelope.sessionId == imageSessionId) { + return message.id; + } + } + } + return null; + } + + /// Get storage statistics + Future> getStorageStats() async { + return await _storageService.getStorageStats(); + } + + /// Get message statistics + Map get messageStats { + return { + 'total': _messages.length, + 'contact': contactMessages.length, + 'channel': channelMessages.length, + 'sar': sarMarkerMessages.length, + 'system': systemMessages.length, + 'sarMarkers': sarMarkers.length, + }; + } + + /// Log a system message (replaces toast notifications) + void logSystemMessage({ + required String text, + String level = 'info', // 'info', 'success', 'warning', 'error' + }) { + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final messageId = '${DateTime.now().millisecondsSinceEpoch}_system_$level'; + + final systemMessage = Message( + id: messageId, + messageType: MessageType.system, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: text, + receivedAt: DateTime.now(), + senderName: level, // Use senderName to store log level + deliveryStatus: MessageDeliveryStatus.received, + ); + + _messages.add(systemMessage); + + // Don't persist system messages to reduce storage + // _persistMessages(); + + notifyListeners(); + } + + /// Get SAR marker statistics + Map get sarMarkerStats { + return { + 'total': sarMarkers.length, + 'foundPerson': foundPersonMarkers.length, + 'fire': fireMarkers.length, + 'stagingArea': stagingAreaMarkers.length, + 'object': objectMarkers.length, + }; + } + + /// Add a sent message with initial status + void addSentMessage(Message message, {Contact? contact}) { + debugPrint('📝 [MessagesProvider] addSentMessage called'); + debugPrint(' Message ID: ${message.id}'); + debugPrint(' Message type: ${message.messageType}'); + debugPrint(' Initial status: ${message.deliveryStatus}'); + debugPrint( + ' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...', + ); + + // Always enhance message with SAR parser to detect SAR markers + var enhancedMessage = SarMessageParser.enhanceMessage(message); + + // Check if it's a drawing message (D:...) and not already marked + // Don't overwrite if already set by the sender (preserves correct drawing ID) + if (DrawingMessageParser.isDrawingMessage(enhancedMessage.text) && + !enhancedMessage.isDrawing) { + // Parse the drawing to get its ID + final drawing = DrawingMessageParser.parseDrawingMessage( + enhancedMessage.text, + senderName: enhancedMessage.senderName, + messageId: enhancedMessage.id, + ); + + // Mark message as drawing and link to drawing ID + enhancedMessage = enhancedMessage.copyWith( + isDrawing: true, + drawingId: drawing?.id, + ); + } + + // Check if it's a voice message (VE1:/V:) and not already marked. + if (!enhancedMessage.isVoice) { + final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text); + if (envelope != null) { + enhancedMessage = enhancedMessage.copyWith( + isVoice: true, + voiceId: envelope.sessionId, + ); + } + } + 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; + } + + // Add message with sending status and mark as read (sent messages are always read) + final sendingMessage = enhancedMessage.copyWith( + deliveryStatus: MessageDeliveryStatus.sending, + 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}'); + + // Store contact mapping for retry logic + if (contact != null) { + _messageContactMap[message.id] = contact; + debugPrint(' ✅ Stored contact mapping for retry logic'); + } + + // If it's a SAR marker message, extract and store the marker + if (sendingMessage.isSarMarker) { + final marker = sendingMessage.toSarMarker(); + if (marker != null && !_removedSarMarkerIds.contains(marker.id)) { + debugPrint(' ✅ SAR Marker created:'); + debugPrint(' marker.id: ${marker.id}'); + debugPrint(' marker.notes: "${marker.notes}"'); + debugPrint(' marker.type: ${marker.type}'); + debugPrint(' marker.displayName: ${marker.displayName}'); + _sarMarkers[marker.id] = marker; + } + } + + _persistMessages(); + notifyListeners(); + 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, + String groupId, + Uint8List recipientPublicKey, + ) { + _groupedMessageMapping[individualMessageId] = (groupId, recipientPublicKey); + debugPrint('📝 [MessagesProvider] Registered grouped message send:'); + debugPrint(' Individual ID: $individualMessageId'); + debugPrint(' Group ID: $groupId'); + debugPrint(' Total mappings: ${_groupedMessageMapping.length}'); + } + + /// Update message status to sent with ACK tag + void markMessageSent( + String messageId, + int expectedAckTag, + int suggestedTimeoutMs, + ) { + debugPrint('📤 [MessagesProvider] markMessageSent called'); + debugPrint(' Message ID: $messageId'); + debugPrint( + ' Expected ACK tag: $expectedAckTag (0x${expectedAckTag.toRadixString(16).padLeft(8, '0')})', + ); + debugPrint(' Timeout: ${suggestedTimeoutMs}ms'); + debugPrint( + ' Current pending ACKs before adding: ${_pendingSentMessages.keys.toList()}', + ); + + // Check if this is an individual message in a grouped send + final groupMapping = _groupedMessageMapping[messageId]; + if (groupMapping != null) { + final (groupId, recipientPublicKey) = groupMapping; + debugPrint(' ✅ This is part of a grouped message: $groupId'); + + // ACK-tracked recipients stay pending until the delivery confirm arrives. + updateGroupedMessageRecipientStatus( + groupId, + recipientPublicKey, + expectedAckTag > 0 + ? MessageDeliveryStatus.sending + : MessageDeliveryStatus.sent, + ); + + // Track the ACK for this specific recipient + if (expectedAckTag > 0 && suggestedTimeoutMs > 0) { + // For grouped messages, multiply timeout by 5x for mesh network propagation + // Clamp at 20 seconds maximum + final scaledTimeout = suggestedTimeoutMs * 5; + final effectiveTimeout = scaledTimeout > 20000 ? 20000 : scaledTimeout; + debugPrint( + ' ⏱️ Radio suggested ${suggestedTimeoutMs}ms, using ${effectiveTimeout}ms (5x${scaledTimeout > 20000 ? ', clamped at 20s' : ''}) for grouped message', + ); + + // Store ACK tag → List of (groupId, recipientPublicKey) + // Multiple recipients can share the same ACK tag + if (!_ackTagToRecipients.containsKey(expectedAckTag)) { + _ackTagToRecipients[expectedAckTag] = []; + } + _ackTagToRecipients[expectedAckTag]!.add((groupId, recipientPublicKey)); + debugPrint( + ' ✅ Added recipient to ACK tag $expectedAckTag → group $groupId, recipient ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}', + ); + debugPrint( + ' 📊 Total recipients for ACK $expectedAckTag: ${_ackTagToRecipients[expectedAckTag]!.length}', + ); + + // Store the mapping so we can update the right recipient on delivery + _pendingSentMessages[expectedAckTag] = Message( + id: messageId, + messageType: MessageType.contact, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + text: '', + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sent, + expectedAckTag: expectedAckTag, + recipientPublicKey: recipientPublicKey, + ); + + debugPrint(' ✅ Added to pending ACKs with list-based mapping'); + + // Start timeout timer for THIS specific recipient using message ID as key + _timeoutTimers[messageId] = Timer( + Duration(milliseconds: effectiveTimeout), + () { + debugPrint( + '⏱️ [MessagesProvider] Timeout for grouped message recipient (message $messageId)', + ); + // Check if this specific recipient is still pending + final recipients = _ackTagToRecipients[expectedAckTag]; + if (recipients != null && recipients.isNotEmpty) { + // Find this specific recipient in the list + final recipientIndex = recipients.indexWhere( + (r) => _listEquals(r.$2, recipientPublicKey), + ); + + if (recipientIndex >= 0) { + final (timeoutGroupId, timeoutRecipientKey) = + recipients[recipientIndex]; + debugPrint(' ⚠️ Timeout fired - marking recipient as failed'); + debugPrint(' Group: $timeoutGroupId'); + debugPrint( + ' Recipient: ${timeoutRecipientKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}', + ); + + // Mark this specific recipient as failed + updateGroupedMessageRecipientStatus( + timeoutGroupId, + timeoutRecipientKey, + MessageDeliveryStatus.failed, + ); + + // Remove this recipient from the list + recipients.removeAt(recipientIndex); + + // Clean up if no more recipients for this ACK + if (recipients.isEmpty) { + _ackTagToRecipients.remove(expectedAckTag); + _pendingSentMessages.remove(expectedAckTag); + } + _groupedMessageMapping.remove(messageId); + _timeoutTimers.remove(messageId); + } else { + debugPrint( + ' ✅ ACK already received for this recipient - ignoring timeout', + ); + } + } else { + debugPrint(' ✅ All ACKs already received - ignoring timeout'); + } + }, + ); + } + + _persistMessages(); + notifyListeners(); + return; + } + + final index = _messages.indexWhere((m) => m.id == messageId); + debugPrint(' Message index in list: $index'); + + if (index != -1) { + final message = _messages[index]; + final contact = _messageContactMap[messageId]; + final effectiveTimeout = _retryManager.calculateAckTimeoutMs( + text: message.text, + contact: contact, + suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null, + ); + debugPrint(' Current status: ${message.deliveryStatus}'); + debugPrint(' Message type: ${message.messageType}'); + debugPrint( + ' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...', + ); + + // Once the device accepts a direct message and returns an ACK tag, the + // send itself succeeded locally even if end-to-end delivery confirmation + // may still arrive later. Keep ACK tracking, but stop showing "waiting". + final updatedMessage = message.copyWith( + deliveryStatus: MessageDeliveryStatus.sent, + expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null, + suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null, + ); + _messages[index] = updatedMessage; + + // Only track and set timeout for direct messages (channel messages have expectedAckTag=0) + if (expectedAckTag > 0) { + // Track by ACK tag for matching with delivery confirmation + _pendingSentMessages[expectedAckTag] = updatedMessage; + _messageAckHistory + .putIfAbsent(messageId, () => {}) + .add(expectedAckTag); + _ackHistoryLookup[expectedAckTag] = (messageId, DateTime.now()); + debugPrint( + ' ✅ Added to pending messages map with ACK: $expectedAckTag', + ); + debugPrint(' Total pending messages: ${_pendingSentMessages.length}'); + debugPrint( + ' Pending ACKs after adding: ${_pendingSentMessages.keys.toList()}', + ); + + // Start timeout timer using message ID as key + _timeoutTimers[messageId] = Timer( + Duration(milliseconds: effectiveTimeout), + () { + debugPrint( + '⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)', + ); + if (_pendingSentMessages.containsKey(expectedAckTag)) { + markMessageFailed(messageId); + } + }, + ); + + debugPrint( + '⏱️ [MessagesProvider] Started ${effectiveTimeout}ms timeout timer for message $messageId (ACK $expectedAckTag)', + ); + } else { + debugPrint( + ' ℹ️ Channel message (no ACK tracking) - marked as sent immediately', + ); + if (message.isChannelMessage) { + _scheduleChannelEchoWarning(messageId); + } + } + + debugPrint(' Calling notifyListeners() to update UI with "sent" status'); + + _persistMessages(); + notifyListeners(); + + debugPrint(' ✅ markMessageSent completed successfully'); + } else { + debugPrint('⚠️ [MessagesProvider] Message not found in list: $messageId'); + debugPrint(' Total messages in list: ${_messages.length}'); + debugPrint(' Recent messages:'); + for (final m in _messages.take(5)) { + debugPrint(' - ID: ${m.id}, Status: ${m.deliveryStatus}'); + } + } + } + + /// Handle echo detection for public channel messages + void handleMessageEcho( + String messageId, + int echoCount, + int snrRaw, + int rssiDbm, + { + Uint8List? pathBytes, + }) { + debugPrint('🔊 [MessagesProvider] handleMessageEcho called'); + debugPrint(' Message ID: $messageId'); + debugPrint(' Echo count: $echoCount'); + debugPrint(' SNR: ${(snrRaw.toSigned(8) / 4.0).toStringAsFixed(2)} dB'); + debugPrint(' RSSI: ${rssiDbm.toSigned(8)} dBm'); + + // Find the message + final index = _messages.indexWhere((m) => m.id == messageId); + if (index != -1) { + final message = _messages[index]; + debugPrint( + ' ✅ 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: 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: $nextEchoCount'); + _persistMessages(); + notifyListeners(); + debugPrint(' ✅ Echo update complete, UI notified'); + } else { + debugPrint(' ⚠️ Message not found in messages list'); + } + } + + /// Update a recipient's status in a grouped message + void updateGroupedMessageRecipientStatus( + String groupId, + Uint8List recipientPublicKey, + MessageDeliveryStatus newStatus, { + int? roundTripTimeMs, + DateTime? deliveredAt, + }) { + debugPrint( + '🔄 [MessagesProvider] updateGroupedMessageRecipientStatus called', + ); + debugPrint(' Group ID: $groupId'); + debugPrint(' New status: $newStatus'); + debugPrint(' RTT: ${roundTripTimeMs}ms'); + + final index = _messages.indexWhere((m) => m.id == groupId); + if (index == -1) { + debugPrint('⚠️ [MessagesProvider] Grouped message not found: $groupId'); + debugPrint( + ' Available message IDs: ${_messages.take(5).map((m) => m.id).join(", ")}', + ); + return; + } + + final message = _messages[index]; + debugPrint(' ✅ Found grouped message at index $index'); + + if (!message.isGroupedMessage) { + debugPrint( + '⚠️ [MessagesProvider] Message is not a grouped message: $groupId', + ); + return; + } + + debugPrint(' Total recipients: ${message.recipients!.length}'); + + // Find and update the recipient + bool recipientFound = false; + final updatedRecipients = message.recipients!.map((recipient) { + // Compare public keys + if (recipient.publicKey.length == recipientPublicKey.length) { + bool matches = true; + for (int i = 0; i < recipient.publicKey.length; i++) { + if (recipient.publicKey[i] != recipientPublicKey[i]) { + matches = false; + break; + } + } + if (matches) { + recipientFound = true; + debugPrint(' ✅ Found recipient: ${recipient.displayName}'); + debugPrint(' Old status: ${recipient.deliveryStatus}'); + debugPrint(' New status: $newStatus'); + return recipient.copyWith( + deliveryStatus: newStatus, + roundTripTimeMs: roundTripTimeMs, + deliveredAt: + deliveredAt ?? + (newStatus == MessageDeliveryStatus.delivered + ? DateTime.now() + : null), + ); + } + } + return recipient; + }).toList(); + + if (!recipientFound) { + debugPrint(' ⚠️ Recipient not found in recipients list!'); + debugPrint( + ' Looking for key: ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}', + ); + debugPrint(' Available recipients:'); + for (final r in message.recipients!) { + debugPrint( + ' - ${r.displayName}: ${r.publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}', + ); + } + } + + // Update the message with new recipient list + _messages[index] = message.copyWith(recipients: updatedRecipients); + + // Update overall message status based on recipients + MessageDeliveryStatus overallStatus; + final allDelivered = updatedRecipients.every( + (r) => r.deliveryStatus == MessageDeliveryStatus.delivered, + ); + final anyFailed = updatedRecipients.any( + (r) => r.deliveryStatus == MessageDeliveryStatus.failed, + ); + final anySending = updatedRecipients.any( + (r) => r.deliveryStatus == MessageDeliveryStatus.sending, + ); + + debugPrint(' Status counts:'); + debugPrint( + ' Delivered: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.delivered).length}', + ); + debugPrint( + ' Sent/Pending: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.sent || r.deliveryStatus == MessageDeliveryStatus.sending).length}', + ); + debugPrint( + ' Failed: ${updatedRecipients.where((r) => r.deliveryStatus == MessageDeliveryStatus.failed).length}', + ); + + if (allDelivered) { + overallStatus = MessageDeliveryStatus.delivered; + } else if (anyFailed && !anySending) { + overallStatus = MessageDeliveryStatus.failed; + } else if (anySending) { + overallStatus = MessageDeliveryStatus.sending; + } else { + overallStatus = MessageDeliveryStatus.sent; + } + + debugPrint(' Overall status: $overallStatus'); + + _messages[index] = _messages[index].copyWith(deliveryStatus: overallStatus); + + debugPrint(' ✅ Message updated, calling notifyListeners()'); + _persistMessages(); + notifyListeners(); + } + + /// Update message status to delivered with RTT + void markMessageDelivered(int ackCode, int roundTripTimeMs) { + _cleanupCompletedAckHistory(); + _cleanupAckHistoryLookup(); + debugPrint( + '🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms', + ); + debugPrint(' Checking recipient list for ACK $ackCode...'); + + // Check if this ACK is for grouped message recipient(s) + final recipients = _ackTagToRecipients[ackCode]; + if (recipients != null && recipients.isNotEmpty) { + // Pop the first recipient from the list (FIFO order) + // This matches the order in which messages were sent + final (groupId, recipientPublicKey) = recipients.removeAt(0); + debugPrint( + ' ✅ Found recipient in list: group $groupId, recipient ${recipientPublicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}', + ); + debugPrint( + ' 📊 Remaining recipients for ACK $ackCode: ${recipients.length}', + ); + + // Find the message ID for this recipient to cancel its timeout + String? messageIdToCancel; + for (final entry in _groupedMessageMapping.entries) { + if (entry.value.$1 == groupId && + _listEquals(entry.value.$2, recipientPublicKey)) { + messageIdToCancel = entry.key; + break; + } + } + + if (messageIdToCancel != null) { + debugPrint(' 🧹 Canceling timeout for message $messageIdToCancel'); + _timeoutTimers[messageIdToCancel]?.cancel(); + _timeoutTimers.remove(messageIdToCancel); + _groupedMessageMapping.remove(messageIdToCancel); + } + + // Update the specific recipient's status to delivered + updateGroupedMessageRecipientStatus( + groupId, + recipientPublicKey, + MessageDeliveryStatus.delivered, + roundTripTimeMs: roundTripTimeMs, + deliveredAt: DateTime.now(), + ); + + // Clean up if no more recipients for this ACK + if (recipients.isEmpty) { + debugPrint( + ' 🧹 All recipients processed for ACK $ackCode, cleaning up', + ); + _ackTagToRecipients.remove(ackCode); + _pendingSentMessages.remove(ackCode); + _rememberCompletedAck(ackCode); + } + + debugPrint( + '✅ [MessagesProvider] Grouped message recipient delivered in ${roundTripTimeMs}ms (ACK $ackCode)', + ); + + _persistMessages(); + notifyListeners(); + + debugPrint(' ✅ notifyListeners() called successfully'); + return; + } + + // Not a grouped message, check for single message + debugPrint(' Not in simple mapping, checking pending messages...'); + debugPrint( + ' Current pending messages: ${_pendingSentMessages.keys.toList()}', + ); + debugPrint(' Total messages in list: ${_messages.length}'); + + // Find message by ACK code + final message = _pendingSentMessages[ackCode]; + if (message != null) { + debugPrint(' ✅ Found message in pending map: ${message.id}'); + + // Single message delivery + final index = _messages.indexWhere((m) => m.id == message.id); + debugPrint(' Message index in list: $index'); + + if (index != -1) { + final updatedMessage = _messages[index].copyWith( + deliveryStatus: MessageDeliveryStatus.delivered, + roundTripTimeMs: roundTripTimeMs, + deliveredAt: DateTime.now(), + ); + _messages[index] = updatedMessage; + + // Cancel timeout timer using message ID + _timeoutTimers[message.id]?.cancel(); + _timeoutTimers.remove(message.id); + + // Remove from pending + _pendingSentMessages.remove(ackCode); + _rememberCompletedAck(ackCode); + _clearAckHistoryForMessage(message.id); + + // Clear retry tracking on successful delivery + _retryManager.clearRetry(message.id); + final deliveredContact = _messageContactMap[message.id]; + if (deliveredContact != null) { + _retryManager.recordDeliverySuccess(deliveredContact); + onDirectMessageDeliveredCallback?.call( + messageId: message.id, + contact: deliveredContact, + message: updatedMessage, + roundTripTimeMs: roundTripTimeMs, + ); + } + + debugPrint( + '✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)', + ); + debugPrint(' Updated status to: ${updatedMessage.deliveryStatus}'); + debugPrint(' Calling notifyListeners() to update UI'); + + _persistMessages(); + notifyListeners(); + + debugPrint(' ✅ notifyListeners() called successfully'); + } else { + debugPrint( + '⚠️ [MessagesProvider] Message not found in messages list (index=-1)', + ); + debugPrint( + ' This should never happen - message was in pending map but not in messages list', + ); + } + } else { + final historicalMatch = _ackHistoryLookup[ackCode]; + if (historicalMatch != null) { + final historicalMessageId = historicalMatch.$1; + final historicalIndex = _messages.indexWhere( + (m) => m.id == historicalMessageId, + ); + if (historicalIndex != -1 && + _messages[historicalIndex].deliveryStatus != + MessageDeliveryStatus.delivered) { + _messages[historicalIndex] = _messages[historicalIndex].copyWith( + deliveryStatus: MessageDeliveryStatus.delivered, + roundTripTimeMs: roundTripTimeMs, + deliveredAt: DateTime.now(), + ); + _timeoutTimers[historicalMessageId]?.cancel(); + _timeoutTimers.remove(historicalMessageId); + _rememberCompletedAck(ackCode); + _clearAckHistoryForMessage(historicalMessageId); + _retryManager.clearRetry(historicalMessageId); + final deliveredContact = _messageContactMap[historicalMessageId]; + if (deliveredContact != null) { + _retryManager.recordDeliverySuccess(deliveredContact); + onDirectMessageDeliveredCallback?.call( + messageId: historicalMessageId, + contact: deliveredContact, + message: _messages[historicalIndex], + roundTripTimeMs: roundTripTimeMs, + ); + } + _persistMessages(); + notifyListeners(); + debugPrint( + '✅ [MessagesProvider] Historical ACK $ackCode matched message $historicalMessageId', + ); + return; + } + } + if (_completedAckHistory.containsKey(ackCode)) { + debugPrint( + 'ℹ️ [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)', + ); + return; + } + debugPrint( + '⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode', + ); + debugPrint(' Pending ACK codes: ${_pendingSentMessages.keys.toList()}'); + debugPrint(' This means either:'); + debugPrint( + ' 1. markMessageSent() was never called for this message (ACK tag not stored)', + ); + debugPrint( + ' 2. The ACK code from PUSH_CODE_SEND_CONFIRMED doesn\'t match the expected ACK tag from RESP_CODE_SENT', + ); + debugPrint(' 3. The message was already delivered or timed out'); + debugPrint( + ' 4. Firmware circular buffer overflow (>8 pending ACKs sent too quickly)', + ); + debugPrint(' Searching all messages for debugging...'); + + // Debug: Search for any message with this ACK tag + final matchingMessages = _messages + .where((m) => m.expectedAckTag == ackCode) + .toList(); + if (matchingMessages.isNotEmpty) { + debugPrint( + ' ⚠️ Found ${matchingMessages.length} message(s) with matching ACK tag but NOT in pending map:', + ); + for (final m in matchingMessages) { + debugPrint( + ' - Message ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}', + ); + } + debugPrint( + ' This indicates the message was sent but never added to _pendingSentMessages map', + ); + debugPrint( + ' Likely cause: markMessageSent() was not called with correct message ID', + ); + } else { + debugPrint(' No messages found with ACK tag $ackCode'); + debugPrint(' Recent sent messages:'); + final sentMessages = _messages + .where((m) => m.isSentMessage) + .take(5) + .toList(); + for (final m in sentMessages) { + debugPrint( + ' - ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}', + ); + } + } + } + } + + /// Update message status to failed (with retry logic) + void markMessageFailed(String messageId) { + final index = _messages.indexWhere((m) => m.id == messageId); + if (index == -1) { + debugPrint( + '⚠️ [MessagesProvider] markMessageFailed: Message not found: $messageId', + ); + return; + } + + final message = _messages[index]; + _clearChannelSendWarning(messageId); + final contact = _messageContactMap[messageId]; + + debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed'); + debugPrint(' Retry attempt: ${message.retryAttempt}'); + debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}'); + debugPrint(' Used flood fallback: ${message.usedFloodFallback}'); + + final routeMetadata = _messageRouteMetadata[messageId]; + final routerFallbackAttempted = + routeMetadata?.routerFallbackAttempted ?? false; + + // Decision tree for retry/final-router-fallback/fail + if (contact != null && _retryManager.canRetry(message, contact)) { + _scheduleRetry(messageId, message, contact); + } else if (contact != null && !routerFallbackAttempted) { + unawaited(_sendWithFinalRouterFallback(messageId, message, contact)); + } else { + _markAsPermanentlyFailed(messageId, message); + } + } + + /// Schedule a retry with exponential backoff. + void _scheduleRetry(String messageId, Message message, Contact contact) { + final nextAttempt = message.retryAttempt + 1; + final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt); + + debugPrint( + '🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttemptsForContact(contact)} for message $messageId', + ); + debugPrint(' Delay: ${delayMs}ms'); + + // Update message with new retry attempt + final index = _messages.indexWhere((m) => m.id == messageId); + if (index != -1) { + _messages[index] = message.copyWith( + retryAttempt: nextAttempt, + deliveryStatus: MessageDeliveryStatus.sending, + lastRetryAt: DateTime.now(), + ); + _clearChannelSendWarning(messageId); + + // Cancel old timeout timer + _timeoutTimers[message.id]?.cancel(); + _timeoutTimers.remove(message.id); + if (message.expectedAckTag != null) { + _pendingSentMessages.remove(message.expectedAckTag); + } + + // Track retry + _retryManager.trackRetry(messageId, nextAttempt); + + notifyListeners(); + + // Schedule actual retry after delay + Timer(Duration(milliseconds: delayMs), () async { + debugPrint( + '⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId', + ); + final currentIndex = _messages.indexWhere((m) => m.id == messageId); + if (currentIndex == -1) { + return; + } + final currentMessage = _messages[currentIndex]; + if (currentMessage.deliveryStatus == MessageDeliveryStatus.delivered) { + return; + } + + if (sendMessageCallback != null) { + final queued = await sendMessageCallback!( + contactPublicKey: contact.publicKey, + text: message.text, + messageId: messageId, + contact: contact, + retryAttempt: nextAttempt, + ); + if (!queued) { + _markAsPermanentlyFailed(messageId, currentMessage); + } + } else { + debugPrint( + '⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry', + ); + _markAsPermanentlyFailed(messageId, currentMessage); + } + }); + + _persistMessages(); + } + } + + Future _sendWithFinalRouterFallback( + String messageId, + Message message, + Contact contact, + ) async { + debugPrint( + '🛟 [MessagesProvider] Trying final router fallback for $messageId', + ); + + final index = _messages.indexWhere((m) => m.id == messageId); + if (index != -1) { + _messages[index] = message.copyWith( + deliveryStatus: MessageDeliveryStatus.sending, + lastRetryAt: DateTime.now(), + ); + _clearChannelSendWarning(messageId); + + _timeoutTimers[message.id]?.cancel(); + _timeoutTimers.remove(message.id); + if (message.expectedAckTag != null) { + _pendingSentMessages.remove(message.expectedAckTag); + } + _clearAckHistoryForMessage(messageId); + + notifyListeners(); + + if (onFinalRouterFallbackCallback == null) { + debugPrint( + '⚠️ [MessagesProvider] onFinalRouterFallbackCallback not set', + ); + _markAsPermanentlyFailed(messageId, _messages[index]); + return; + } + + final queued = await onFinalRouterFallbackCallback!( + messageId: messageId, + contact: contact, + message: _messages[index], + ); + if (!queued) { + _markAsPermanentlyFailed(messageId, _messages[index]); + } + + _persistMessages(); + } + } + + /// Mark message as permanently failed + void _markAsPermanentlyFailed(String messageId, Message message) { + debugPrint('❌ [MessagesProvider] Message $messageId permanently failed'); + + final index = _messages.indexWhere((m) => m.id == messageId); + if (index != -1) { + _messages[index] = message.copyWith( + deliveryStatus: MessageDeliveryStatus.failed, + ); + + // Cancel timeout timer if it exists + _timeoutTimers[message.id]?.cancel(); + _timeoutTimers.remove(message.id); + if (message.expectedAckTag != null) { + _pendingSentMessages.remove(message.expectedAckTag); + } + _clearAckHistoryForMessage(messageId); + _removePendingDeliveredRouteRefresh(messageId); + + // Clear retry tracking + _retryManager.clearRetry(messageId); + + final failedContact = _messageContactMap[messageId]; + if (failedContact != null && + onFinalDirectMessageFailureCallback != null) { + unawaited( + onFinalDirectMessageFailureCallback!( + messageId: messageId, + contact: failedContact, + message: _messages[index], + ), + ); + } + + _persistMessages(); + notifyListeners(); + } + } + + /// Reset an existing failed message back into a sending state so a manual + /// retry can reuse the same record instead of appending a duplicate. + bool prepareMessageForRetry(String messageId) { + final index = _messages.indexWhere((m) => m.id == messageId); + if (index == -1) { + debugPrint( + '⚠️ [MessagesProvider] prepareMessageForRetry: Message not found: $messageId', + ); + return false; + } + + final message = _messages[index]; + + _timeoutTimers[message.id]?.cancel(); + _timeoutTimers.remove(message.id); + if (message.expectedAckTag != null) { + _pendingSentMessages.remove(message.expectedAckTag); + } + _clearAckHistoryForMessage(messageId); + _removePendingDeliveredRouteRefresh(messageId); + _retryManager.clearRetry(messageId); + _messageRouteMetadata.remove(messageId); + onManualRetryPreparedCallback?.call(messageId); + + _messages[index] = Message( + id: message.id, + messageType: message.messageType, + senderPublicKeyPrefix: message.senderPublicKeyPrefix, + channelIdx: message.channelIdx, + pathLen: message.pathLen, + pathBytes: message.pathBytes, + textType: message.textType, + senderTimestamp: message.senderTimestamp, + text: message.text, + isSarMarker: message.isSarMarker, + sarGpsCoordinates: message.sarGpsCoordinates, + sarCustomMapPoint: message.sarCustomMapPoint, + sarCustomMapId: message.sarCustomMapId, + sarNotes: message.sarNotes, + sarCustomEmoji: message.sarCustomEmoji, + sarColorIndex: message.sarColorIndex, + receivedAt: message.receivedAt, + senderName: message.senderName, + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: message.recipientPublicKey, + retryAttempt: 0, + lastRetryAt: DateTime.now(), + usedFloodFallback: false, + isRead: message.isRead, + echoCount: message.echoCount, + firstEchoAt: message.firstEchoAt, + lastEchoSnrRaw: message.lastEchoSnrRaw, + lastEchoRssiDbm: message.lastEchoRssiDbm, + lastEchoAt: message.lastEchoAt, + isDrawing: message.isDrawing, + drawingId: message.drawingId, + groupId: message.groupId, + recipients: message.recipients, + isVoice: message.isVoice, + voiceId: message.voiceId, + ); + _clearChannelSendWarning(messageId); + + _persistMessages(); + notifyListeners(); + return true; + } + + /// Resend a failed message + Future resendMessage(String messageId, {Contact? contact}) async { + final index = _messages.indexWhere((m) => m.id == messageId); + if (index == -1) { + debugPrint( + '⚠️ [MessagesProvider] resendMessage: Message not found: $messageId', + ); + return; + } + + final message = _messages[index]; + final resolvedContact = contact ?? _messageContactMap[messageId]; + + if (resolvedContact == null) { + debugPrint( + '⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId', + ); + return; + } + + debugPrint('🔁 [MessagesProvider] Resending message $messageId'); + + _messageContactMap[messageId] = resolvedContact; + final prepared = prepareMessageForRetry(messageId); + if (!prepared) { + return; + } + + // Send again + if (sendMessageCallback != null) { + final queued = await sendMessageCallback!( + contactPublicKey: resolvedContact.publicKey, + text: message.text, + messageId: messageId, + contact: resolvedContact, + retryAttempt: 0, + ); + if (!queued) { + _markAsPermanentlyFailed(messageId, _messages[index]); + } + } else { + debugPrint( + '⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend', + ); + _markAsPermanentlyFailed(messageId, _messages[index]); + } + + _persistMessages(); + } + + @override + void dispose() { + _cancelAllTimers(); + _completedAckHistory.clear(); + _messageAckHistory.clear(); + _ackHistoryLookup.clear(); + + // Clear retry manager + _retryManager.clearAll(); + + super.dispose(); + } + + /// Cancel all pending timers (timeout + echo warning). + void _cancelAllTimers() { + for (final timer in _timeoutTimers.values) { + timer.cancel(); + } + _timeoutTimers.clear(); + for (final timer in _channelEchoWarningTimers.values) { + timer.cancel(); + } + _channelEchoWarningTimers.clear(); + _channelEchoWarningMessageIds.clear(); + } + + void _rememberCompletedAck(int ackCode) { + _completedAckHistory[ackCode] = DateTime.now(); + _cleanupCompletedAckHistory(); + } + + void _cleanupCompletedAckHistory({ + Duration maxAge = const Duration(minutes: 15), + }) { + final cutoff = DateTime.now().subtract(maxAge); + final staleAcks = _completedAckHistory.entries + .where((entry) => entry.value.isBefore(cutoff)) + .map((entry) => entry.key) + .toList(); + for (final ack in staleAcks) { + _completedAckHistory.remove(ack); + } + } + + void _clearAckHistoryForMessage(String messageId) { + final ackTags = _messageAckHistory.remove(messageId); + if (ackTags == null) { + return; + } + for (final ack in ackTags) { + _ackHistoryLookup.remove(ack); + } + } + + void _cleanupAckHistoryLookup({ + Duration maxAge = const Duration(minutes: 15), + }) { + final cutoff = DateTime.now().subtract(maxAge); + final staleAcks = _ackHistoryLookup.entries + .where((entry) => entry.value.$2.isBefore(cutoff)) + .map((entry) => entry.key) + .toList(); + for (final ack in staleAcks) { + final messageId = _ackHistoryLookup.remove(ack)?.$1; + if (messageId == null) { + continue; + } + final history = _messageAckHistory[messageId]; + history?.remove(ack); + if (history != null && history.isEmpty) { + _messageAckHistory.remove(messageId); + } + } + } + + void _prunePendingDeliveredRouteRefresh() { + if (_pendingDeliveredRouteRefreshByContact.isEmpty) { + return; + } + + final cutoff = DateTime.now().subtract(_deliveredRouteRefreshWindow); + final emptyKeys = []; + 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 = []; + 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, + }); +} diff --git a/lib/providers/offline_tiles_provider.dart b/lib/providers/offline_tiles_provider.dart new file mode 100644 index 0000000..d66b905 --- /dev/null +++ b/lib/providers/offline_tiles_provider.dart @@ -0,0 +1,582 @@ +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? _downloadSubscription; + StreamSubscription>? _peersSubscription; + bool _isDisposed = false; + + // Drawing state + DrawingMode _drawingMode = DrawingMode.none; + DrawingMode _downloadSelectionMode = DrawingMode.none; + final List> _polygons = []; + List _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 _tileOverlays = []; + + // Cache info + int _cacheSizeBytes = 0; + + // Sharing state + bool _isServerRunning = false; + Set _discoveredPeers = {}; + List _peerCatalogs = []; + bool _isFetchingCatalogs = false; + bool _isSyncing = false; + String _syncStatus = ''; + double _syncProgress = 0; + + // Local style info + List _localStyles = []; + + // Coverage overlay — which cached style's tiles to show on the map + StyleInfo? _coverageStyle; + List _coverageOverlays = []; + + // Getters + DrawingMode get drawingMode => _drawingMode; + DrawingMode get downloadSelectionMode => _downloadSelectionMode; + List> get polygons => List.unmodifiable(_polygons); + List 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 get tileOverlays => _tileOverlays; + int get cacheSizeBytes => _cacheSizeBytes; + bool get hasPolygons => _polygons.isNotEmpty; + bool get isServerRunning => _isServerRunning; + Set get discoveredPeers => _discoveredPeers; + List get peerCatalogs => _peerCatalogs; + bool get isFetchingCatalogs => _isFetchingCatalogs; + bool get isSyncing => _isSyncing; + String get syncStatus => _syncStatus; + double get syncProgress => _syncProgress; + List get localStyles => _localStyles; + StyleInfo? get coverageStyle => _coverageStyle; + List 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 startDownloadSelectionMode(DrawingMode mode) { + _polygons.clear(); + _currentVertices = []; + _rectangleFirstCorner = null; + _downloadSelectionMode = mode; + _drawingMode = mode; + 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 setCurrentViewBounds({ + required double north, + required double south, + required double east, + required double west, + }) { + _polygons + ..clear() + ..add([ + LatLng(north, west), + LatLng(north, east), + LatLng(south, east), + LatLng(south, west), + ]); + _currentVertices = []; + _rectangleFirstCorner = null; + _downloadSelectionMode = DrawingMode.none; + _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(); + } + + void setSelectedLayerIfDifferent(MapLayer layer) { + if (_selectedLayer.type == layer.type && + _selectedLayer.urlTemplate == layer.urlTemplate) { + return; + } + _selectedLayer = layer; + notifyListeners(); + } + + // Download control + + Future 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, + headers: _selectedLayer.headers, + ); + + 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; + _tileOverlays.clear(); + notifyListeners(); + + case TileDownloadCancelled(): + _isDownloading = false; + _tileOverlays.clear(); + 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 refreshCacheSize() async { + _cacheSizeBytes = await _cache.getCacheSize(); + notifyListeners(); + } + + Future deleteStyle(StyleInfo style) async { + if (_coverageStyle?.hash == style.hash) hideCoverage(); + await _cache.deleteStyle(style.hash); + await refreshCacheSize(); + await refreshLocalStyles(); + } + + Future 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 showCoverage(StyleInfo style) async { + if (_coverageStyle?.hash == style.hash) { + // Toggle off if same style tapped again + hideCoverage(); + return; + } + + _coverageStyle = style; + _coverageOverlays = []; + notifyListeners(); + + final tiles = await _cache.listTilesForStyle(style.hash); + + // Convert cached tile coordinates to bound overlays + final overlays = []; + for (final tile in tiles) { + final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.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 toggleServer() async { + if (_isServerRunning) { + await _sharing.stopServer(); + _isServerRunning = false; + } else { + await _sharing.startServer(); + _isServerRunning = _sharing.isRunning; + } + notifyListeners(); + } + + Future startPeerDiscovery() async { + _peersSubscription?.cancel(); + _peersSubscription = _sharing.peersStream.listen((peers) { + _discoveredPeers = peers; + notifyListeners(); + }); + await _sharing.startDiscovery(); + } + + Future stopPeerDiscovery() async { + _peersSubscription?.cancel(); + _peersSubscription = null; + await _sharing.stopPeerDiscovery(); + _discoveredPeers = {}; + if (!_isDisposed) { + 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 refreshLocalStyles() async { + _localStyles = await _cache.listStylesDetailed(); + notifyListeners(); + } + + /// Fetch catalogs from all discovered peers to see what they have. + Future 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 syncStyleFromPeers(StyleInfo style) async { + if (_isSyncing) return; + + // Find all peers that have this style + final peersWithStyle = []; + 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() { + _isDisposed = true; + _downloadSubscription?.cancel(); + _downloadService?.dispose(); + _peersSubscription?.cancel(); + super.dispose(); + } +} diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart new file mode 100644 index 0000000..83d611f --- /dev/null +++ b/lib/providers/sensors_provider.dart @@ -0,0 +1,1058 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/contact.dart'; +import '../services/profiles_feature_service.dart'; +import '../widgets/sensors/sensor_telemetry_card.dart'; +import 'connection_provider.dart'; +import 'contacts_provider.dart'; + +enum SensorRefreshState { idle, refreshing, success, timeout, unavailable } + +class SensorHistorySample { + final DateTime timestamp; + final Map values; + + const SensorHistorySample({required this.timestamp, required this.values}); + + factory SensorHistorySample.fromJson(Map json) { + final rawValues = json['values'] as Map? ?? const {}; + return SensorHistorySample( + timestamp: DateTime.fromMillisecondsSinceEpoch( + (json['timestampMillis'] as num?)?.toInt() ?? 0, + ), + values: rawValues.map( + (key, value) => MapEntry(key, (value as num).toDouble()), + ), + ); + } + + Map 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 supportedAutoRefreshIntervals = [ + 0, + 5, + 15, + 30, + 60, + 360, + 720, + 1440, + ]; + static const Set _defaultVisibleFields = { + 'voltage', + 'battery', + 'temperature', + 'humidity', + 'pressure', + 'gps', + }; + static const List _defaultMetricOrder = [ + 'voltage', + 'battery', + 'temperature', + 'humidity', + 'pressure', + 'gps', + ]; + + final List _watchedSensorKeys = []; + final Map _refreshStates = + {}; + final Map _refreshStateUpdatedAt = {}; + final Map> _visibleFieldsBySensor = + >{}; + final Map> _fieldSpansBySensor = + >{}; + final Map> _metricLabelsBySensor = + >{}; + final Map> _metricOrderBySensor = + >{}; + final Map _autoRefreshMinutesBySensor = {}; + final Map _lastRefreshAttemptAt = {}; + final Map> _historyBySensor = + >{}; + bool _isLoaded = false; + bool _isRefreshingAll = false; + bool _isRunningAutoRefreshTick = false; + + SensorsProvider() { + unawaited(_loadWatchedSensors()); + } + + String _key(String baseKey) => ProfileStorageScope.scopedKey(baseKey); + + List get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys); + bool get isLoaded => _isLoaded; + bool get isRefreshingAll => _isRefreshingAll; + + SensorRefreshState stateFor(String publicKeyHex) => + _refreshStates[publicKeyHex] ?? SensorRefreshState.idle; + + Future _loadWatchedSensors() async { + try { + final prefs = await SharedPreferences.getInstance(); + final stored = + prefs.getStringList(_key(_watchedSensorsKey)) ?? []; + final storedMetricsJson = prefs.getString(_key(_visibleSensorMetricsKey)); + final storedSpansJson = prefs.getString(_key(_fieldSpanKey)); + final storedLabelsJson = prefs.getString(_key(_metricLabelKey)); + final storedOrderJson = prefs.getString(_key(_metricOrderKey)); + final storedAutoRefreshJson = prefs.getString( + _key(_autoRefreshMinutesKey), + ); + final storedHistoryJson = prefs.getString(_key(_historyKey)); + _watchedSensorKeys + ..clear() + ..addAll(stored); + _refreshStates.clear(); + _refreshStateUpdatedAt.clear(); + _visibleFieldsBySensor.clear(); + _fieldSpansBySensor.clear(); + _metricLabelsBySensor.clear(); + _metricOrderBySensor.clear(); + _autoRefreshMinutesBySensor.clear(); + _lastRefreshAttemptAt.clear(); + _historyBySensor.clear(); + if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) { + final decoded = jsonDecode(storedMetricsJson) as Map; + for (final entry in decoded.entries) { + _visibleFieldsBySensor[entry.key] = (entry.value as List) + .cast() + .toSet(); + } + } + if (storedSpansJson != null && storedSpansJson.isNotEmpty) { + final decoded = jsonDecode(storedSpansJson) as Map; + for (final entry in decoded.entries) { + _fieldSpansBySensor[entry.key] = (entry.value as Map) + .map((key, value) => MapEntry(key, value as int)); + } + } + if (storedLabelsJson != null && storedLabelsJson.isNotEmpty) { + final decoded = jsonDecode(storedLabelsJson) as Map; + for (final entry in decoded.entries) { + _metricLabelsBySensor[entry.key] = + (entry.value as Map).map( + (key, value) => MapEntry(key, value as String), + ); + } + } + if (storedOrderJson != null && storedOrderJson.isNotEmpty) { + final decoded = jsonDecode(storedOrderJson) as Map; + for (final entry in decoded.entries) { + _metricOrderBySensor[entry.key] = (entry.value as List) + .cast() + .toList(); + } + } + if (storedAutoRefreshJson != null && storedAutoRefreshJson.isNotEmpty) { + final decoded = + jsonDecode(storedAutoRefreshJson) as Map; + for (final entry in decoded.entries) { + final minutes = _normalizeAutoRefreshMinutes( + (entry.value as num).toInt(), + ); + if (minutes > 0) { + _autoRefreshMinutesBySensor[entry.key] = minutes; + } + } + } + if (storedHistoryJson != null && storedHistoryJson.isNotEmpty) { + final decoded = jsonDecode(storedHistoryJson) as Map; + for (final entry in decoded.entries) { + final rawSamples = entry.value as List? ?? const []; + final samples = rawSamples + .whereType>() + .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), + ); + for (final key in _watchedSensorKeys) { + _visibleFieldsBySensor.putIfAbsent( + key, + () => Set.from(_defaultVisibleFields), + ); + _fieldSpansBySensor.putIfAbsent(key, () => {}); + _metricLabelsBySensor.putIfAbsent(key, () => {}); + _metricOrderBySensor.putIfAbsent( + key, + () => List.from(_defaultMetricOrder), + ); + } + } catch (e) { + debugPrint('Error loading watched sensors: $e'); + } finally { + _isLoaded = true; + notifyListeners(); + } + } + + Future reloadProfileScopedState() async { + _isLoaded = false; + await _loadWatchedSensors(); + } + + Future _persistWatchedSensors() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_key(_watchedSensorsKey), _watchedSensorKeys); + } catch (e) { + debugPrint('Error saving watched sensors: $e'); + } + } + + Future _persistVisibleMetrics() async { + try { + final prefs = await SharedPreferences.getInstance(); + final encoded = >{}; + for (final entry in _visibleFieldsBySensor.entries) { + encoded[entry.key] = entry.value.toList(); + } + await prefs.setString( + _key(_visibleSensorMetricsKey), + jsonEncode(encoded), + ); + } catch (e) { + debugPrint('Error saving visible sensor metrics: $e'); + } + } + + Future _persistFieldSpans() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key(_fieldSpanKey), + jsonEncode(_fieldSpansBySensor), + ); + } catch (e) { + debugPrint('Error saving sensor field spans: $e'); + } + } + + Future _persistMetricLabels() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key(_metricLabelKey), + jsonEncode(_metricLabelsBySensor), + ); + } catch (e) { + debugPrint('Error saving sensor metric labels: $e'); + } + } + + Future _persistMetricOrder() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key(_metricOrderKey), + jsonEncode(_metricOrderBySensor), + ); + } catch (e) { + debugPrint('Error saving sensor metric order: $e'); + } + } + + Future _persistAutoRefreshMinutes() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key(_autoRefreshMinutesKey), + jsonEncode(_autoRefreshMinutesBySensor), + ); + } catch (e) { + debugPrint('Error saving sensor auto refresh minutes: $e'); + } + } + + Future _persistHistory() async { + try { + final prefs = await SharedPreferences.getInstance(); + final encoded = {}; + 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 visibleFieldsFor(String publicKeyHex) => Set.unmodifiable( + _visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields, + ); + + Set effectiveVisibleFieldsFor( + String publicKeyHex, + Iterable availableFieldKeys, + ) { + final storedVisibleFields = + _visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields; + if (!_shouldAutoIncludeAvailableFields(publicKeyHex, storedVisibleFields)) { + return Set.unmodifiable(storedVisibleFields); + } + + return Set.unmodifiable({ + ...storedVisibleFields, + ...availableFieldKeys, + }); + } + + bool showsField(String publicKeyHex, String fieldKey) => + visibleFieldsFor(publicKeyHex).contains(fieldKey); + + int fieldSpanFor(String publicKeyHex, String fieldKey) { + final sensorSpans = _fieldSpansBySensor[publicKeyHex]; + final span = sensorSpans?[fieldKey] ?? 1; + return span == 2 ? 2 : 1; + } + + List metricOrderFor( + String publicKeyHex, + Iterable availableFieldKeys, + ) { + final available = availableFieldKeys.toList(); + final availableSet = available.toSet(); + final ordered = []; + final seen = {}; + final stored = + _metricOrderBySensor[publicKeyHex] ?? + List.from(_defaultMetricOrder); + + for (final fieldKey in stored) { + if (availableSet.contains(fieldKey) && seen.add(fieldKey)) { + ordered.add(fieldKey); + } + } + for (final fieldKey in available) { + if (seen.add(fieldKey)) { + ordered.add(fieldKey); + } + } + return List.unmodifiable(ordered); + } + + Map labelOverridesFor(String publicKeyHex) => + Map.unmodifiable( + _metricLabelsBySensor[publicKeyHex] ?? const {}, + ); + + String? labelOverrideFor(String publicKeyHex, String fieldKey) => + _metricLabelsBySensor[publicKeyHex]?[fieldKey]; + + int autoRefreshMinutesFor(String publicKeyHex) => + _autoRefreshMinutesBySensor[publicKeyHex] ?? 0; + + List historyFor(String publicKeyHex) => + List.unmodifiable( + _historyBySensor[publicKeyHex] ?? const [], + ); + + Future setAutoRefreshMinutes(String publicKeyHex, int minutes) async { + final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes); + final currentMinutes = autoRefreshMinutesFor(publicKeyHex); + if (currentMinutes == normalizedMinutes) { + return; + } + + if (normalizedMinutes == 0) { + _autoRefreshMinutesBySensor.remove(publicKeyHex); + } else { + _autoRefreshMinutesBySensor[publicKeyHex] = normalizedMinutes; + } + await _persistAutoRefreshMinutes(); + notifyListeners(); + } + + static int _normalizeAutoRefreshMinutes(int minutes) { + if (minutes <= 0) { + return 0; + } + + if (supportedAutoRefreshIntervals.contains(minutes)) { + return minutes; + } + + final positiveIntervals = supportedAutoRefreshIntervals.where( + (value) => value > 0, + ); + var bestMatch = positiveIntervals.first; + var bestDistance = (minutes - bestMatch).abs(); + + for (final interval in positiveIntervals.skip(1)) { + final distance = (minutes - interval).abs(); + if (distance < bestDistance) { + bestMatch = interval; + bestDistance = distance; + } + } + + return bestMatch; + } + + List dueAutoRefreshSensorKeys({DateTime? now}) { + final refreshTime = now ?? DateTime.now(); + final dueKeys = []; + + for (final key in _watchedSensorKeys) { + final minutes = autoRefreshMinutesFor(key); + if (minutes <= 0) { + continue; + } + + final lastRefreshAt = _lastRefreshAttemptAt[key]; + if (lastRefreshAt == null || + refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes)) { + dueKeys.add(key); + } + } + + return List.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 toggleMetric( + String publicKeyHex, + String fieldKey, + bool visible, + ) async { + final visibleFields = _visibleFieldsBySensor.putIfAbsent( + publicKeyHex, + () => Set.from(_defaultVisibleFields), + ); + final metricOrder = _metricOrderBySensor.putIfAbsent( + publicKeyHex, + () => List.from(_defaultMetricOrder), + ); + var shouldPersistOrder = false; + if (visible) { + visibleFields.add(fieldKey); + if (!metricOrder.contains(fieldKey)) { + metricOrder.add(fieldKey); + shouldPersistOrder = true; + } + } else { + if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) { + return; + } + visibleFields.remove(fieldKey); + } + await _persistVisibleMetrics(); + if (shouldPersistOrder) { + await _persistMetricOrder(); + } + notifyListeners(); + } + + Future setFieldSpan( + String publicKeyHex, + String fieldKey, + int span, + ) async { + final sensorSpans = _fieldSpansBySensor.putIfAbsent( + publicKeyHex, + () => {}, + ); + sensorSpans[fieldKey] = span == 2 ? 2 : 1; + await _persistFieldSpans(); + notifyListeners(); + } + + Future setMetricLabel( + String publicKeyHex, + String fieldKey, + String? label, + ) async { + final sensorLabels = _metricLabelsBySensor.putIfAbsent( + publicKeyHex, + () => {}, + ); + final trimmed = label?.trim(); + if (trimmed == null || trimmed.isEmpty) { + sensorLabels.remove(fieldKey); + } else { + sensorLabels[fieldKey] = trimmed; + } + if (sensorLabels.isEmpty) { + _metricLabelsBySensor.remove(publicKeyHex); + } + await _persistMetricLabels(); + notifyListeners(); + } + + Future moveMetric( + String publicKeyHex, { + required List availableFieldKeys, + required int oldIndex, + required int newIndex, + }) async { + if (oldIndex < 0 || + newIndex < 0 || + oldIndex >= availableFieldKeys.length || + newIndex >= availableFieldKeys.length || + oldIndex == newIndex) { + return; + } + + final reordered = List.from( + metricOrderFor(publicKeyHex, availableFieldKeys), + ); + final fieldKey = reordered.removeAt(oldIndex); + reordered.insert(newIndex, fieldKey); + + final storedTail = + (_metricOrderBySensor[publicKeyHex] ?? _defaultMetricOrder).where( + (key) => !reordered.contains(key), + ); + _metricOrderBySensor[publicKeyHex] = [...reordered, ...storedTail]; + await _persistMetricOrder(); + notifyListeners(); + } + + bool isWatched(String publicKeyHex) => + _watchedSensorKeys.contains(publicKeyHex); + + Contact? selfContact( + ContactsProvider contactsProvider, + ConnectionProvider connectionProvider, + ) { + final selfKey = connectionProvider.deviceInfo.publicKey; + if (selfKey == null || selfKey.isEmpty) { + return null; + } + + final existing = contactsProvider.findContactByKey( + Uint8List.fromList(selfKey), + ); + final selfTelemetry = contactsProvider.selfTelemetry; + if (existing != null) { + return selfTelemetry == null + ? existing + : existing.copyWith(telemetry: selfTelemetry); + } + + return _buildSelfCandidate(connectionProvider, telemetry: selfTelemetry); + } + + Future addSensor(Contact contact) async { + if (!contact.isChat && !contact.isRepeater && !contact.isSensor) { + return; + } + if (_watchedSensorKeys.contains(contact.publicKeyHex)) { + return; + } + + _watchedSensorKeys.add(contact.publicKeyHex); + final initialVisibleFields = _initialVisibleFieldsForContact(contact); + await _persistWatchedSensors(); + _visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields; + _fieldSpansBySensor[contact.publicKeyHex] = {'gps': 2}; + _metricLabelsBySensor[contact.publicKeyHex] = {}; + _metricOrderBySensor[contact.publicKeyHex] = metricOrderFor( + contact.publicKeyHex, + initialVisibleFields, + ); + await _persistVisibleMetrics(); + await _persistFieldSpans(); + await _persistMetricLabels(); + await _persistMetricOrder(); + notifyListeners(); + } + + bool _shouldAutoIncludeAvailableFields( + String publicKeyHex, + Set storedVisibleFields, + ) { + if (!setEquals(storedVisibleFields, _defaultVisibleFields)) { + return false; + } + + final storedOrder = _metricOrderBySensor[publicKeyHex]; + if (storedOrder != null && !listEquals(storedOrder, _defaultMetricOrder)) { + return false; + } + + final storedLabels = _metricLabelsBySensor[publicKeyHex]; + if (storedLabels != null && storedLabels.isNotEmpty) { + return false; + } + + final storedSpans = _fieldSpansBySensor[publicKeyHex]; + if (storedSpans != null && + !(storedSpans.length == 1 && storedSpans['gps'] == 2)) { + return false; + } + + return true; + } + + Set _initialVisibleFieldsForContact(Contact contact) { + return {..._defaultVisibleFields, ...sensorMetricKeysFor(contact)}; + } + + Future removeSensor(String publicKeyHex) async { + _watchedSensorKeys.remove(publicKeyHex); + _refreshStates.remove(publicKeyHex); + _refreshStateUpdatedAt.remove(publicKeyHex); + _visibleFieldsBySensor.remove(publicKeyHex); + _fieldSpansBySensor.remove(publicKeyHex); + _metricLabelsBySensor.remove(publicKeyHex); + _metricOrderBySensor.remove(publicKeyHex); + _autoRefreshMinutesBySensor.remove(publicKeyHex); + _lastRefreshAttemptAt.remove(publicKeyHex); + _historyBySensor.remove(publicKeyHex); + await _persistWatchedSensors(); + await _persistVisibleMetrics(); + await _persistFieldSpans(); + await _persistMetricLabels(); + await _persistMetricOrder(); + await _persistAutoRefreshMinutes(); + await _persistHistory(); + notifyListeners(); + } + + Future 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 availableCandidates( + ContactsProvider contactsProvider, { + ConnectionProvider? connectionProvider, + }) { + final candidates = [ + ...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 displaySensorKeys({ + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) { + if (_watchedSensorKeys.isNotEmpty) { + return List.unmodifiable(_watchedSensorKeys); + } + + final self = selfContact(contactsProvider, connectionProvider); + if (self == null) { + return const []; + } + + return [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 refreshAll({ + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) async { + if (_isRefreshingAll) { + return; + } + + final self = selfContact(contactsProvider, connectionProvider); + final keysToRefresh = [ + if (self != null) self.publicKeyHex, + ..._watchedSensorKeys.where( + (key) => self == null || key != self.publicKeyHex, + ), + ]; + if (keysToRefresh.isEmpty) { + return; + } + + _isRefreshingAll = true; + notifyListeners(); + + try { + for (final key in keysToRefresh) { + await refreshSensor( + publicKeyHex: key, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + } + } finally { + _isRefreshingAll = false; + notifyListeners(); + } + } + + Future refreshSensor({ + required String publicKeyHex, + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + DateTime? requestedAt, + }) async { + if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) { + return; + } + + _lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now(); + + final contact = contactForDisplay( + publicKeyHex, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + + if (contact == null) { + _setRefreshState(publicKeyHex, SensorRefreshState.unavailable); + return; + } + + _setRefreshState(publicKeyHex, SensorRefreshState.refreshing); + + final result = await connectionProvider.smartPing( + contactPublicKey: contact.publicKey, + hasPath: contact.hasPath, + ); + + _setRefreshState( + publicKeyHex, + result.success ? SensorRefreshState.success : SensorRefreshState.timeout, + ); + } + + Future refreshDueSensors({ + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + DateTime? now, + }) async { + if (!connectionProvider.deviceInfo.isConnected || + _isRefreshingAll || + _isRunningAutoRefreshTick) { + return; + } + + final refreshTime = now ?? DateTime.now(); + final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime).toList(); + final self = selfContact(contactsProvider, connectionProvider); + if (self != null && + !dueKeys.contains(self.publicKeyHex) && + _isRefreshDueForInterval( + self.publicKeyHex, + interval: selfAutoRefreshInterval, + refreshTime: refreshTime, + )) { + dueKeys.insert(0, self.publicKeyHex); + } + if (dueKeys.isEmpty) { + return; + } + + _isRunningAutoRefreshTick = true; + try { + for (final key in dueKeys) { + await refreshSensor( + publicKeyHex: key, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + requestedAt: refreshTime, + ); + } + } finally { + _isRunningAutoRefreshTick = false; + } + } + + Future captureTrackedTelemetryHistory({ + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) async { + final trackedKeys = { + ..._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 = []; + + for (final entry in _refreshStates.entries) { + if (entry.value != SensorRefreshState.success) { + continue; + } + + final updatedAt = _refreshStateUpdatedAt[entry.key]; + if (updatedAt == null || !updatedAt.isAfter(cutoff)) { + keysToClear.add(entry.key); + } + } + + if (keysToClear.isEmpty) { + return; + } + + for (final key in keysToClear) { + _refreshStates.remove(key); + _refreshStateUpdatedAt.remove(key); + } + notifyListeners(); + } + + void _setRefreshState(String publicKeyHex, SensorRefreshState state) { + _refreshStates[publicKeyHex] = state; + _refreshStateUpdatedAt[publicKeyHex] = DateTime.now(); + notifyListeners(); + } + + Contact? _buildSelfCandidate( + ConnectionProvider connectionProvider, { + ContactTelemetry? telemetry, + }) { + final deviceInfo = connectionProvider.deviceInfo; + final selfKey = deviceInfo.publicKey; + if (selfKey == null || selfKey.isEmpty) { + return null; + } + + return Contact( + publicKey: Uint8List.fromList(selfKey), + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: deviceInfo.selfName ?? 'My Device', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: deviceInfo.advLat ?? 0, + advLon: deviceInfo.advLon ?? 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: telemetry, + ); + } + + 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, + () => [], + ); + 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 _historyValuesForTelemetry(ContactTelemetry telemetry) { + final values = {}; + + 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'; + } +} diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart new file mode 100644 index 0000000..6a6a84d --- /dev/null +++ b/lib/providers/voice_provider.dart @@ -0,0 +1,470 @@ +import 'dart:async'; +import 'dart:convert'; +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'; +import '../services/voice_player_service.dart'; + +/// Reassembly state for one voice session. +class VoiceSession { + final String sessionId; + final VoicePacketMode mode; + final int total; + final List packets; // indexed by packet.index + DateTime? firstPacketAt; + DateTime? lastPacketAt; + + VoiceSession({ + required this.sessionId, + required this.mode, + required this.total, + }) : packets = List.filled(total, null); + + int get receivedCount => packets.where((p) => p != null).length; + bool get isComplete => receivedCount == total; + + Duration? estimateRemaining() { + if (isComplete) return Duration.zero; + if (firstPacketAt == null || lastPacketAt == null) return null; + if (receivedCount < 2) return null; + + final elapsedMs = lastPacketAt!.difference(firstPacketAt!).inMilliseconds; + if (elapsedMs <= 0) return null; + final avgMsPerPacket = elapsedMs / (receivedCount - 1); + final remaining = total - receivedCount; + if (remaining <= 0) return Duration.zero; + return Duration(milliseconds: (avgMsPerPacket * remaining).round()); + } + + /// Total estimated audio duration in seconds (sum of all received packets). + double get estimatedDurationSeconds { + var ms = 0; + for (final p in packets) { + if (p != null) ms += p.durationMs; + } + return ms / 1000.0; + } +} + +/// Manages incoming voice packet sessions and coordinates playback. +class VoiceProvider with ChangeNotifier { + static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1'; + static const int maxDirectPayloadHops = 3; + final VoiceCodecService _codec; + final VoicePlayerService _player; + late final StreamSubscription _playerEventsSub; + + /// Active sessions keyed by sessionId. + final Map _sessions = {}; + final Set _ignoredIncomingSessions = {}; + + /// Currently playing session ID, or null. + String? _playingSessionId; + + /// Hook for sending a raw voice payload to a destination contact path. + Future Function({ + required Uint8List contactPath, + required int contactPathLen, + required Uint8List payload, + })? + sendRawPacketCallback; + + final Map _outgoingSessions = {}; + + VoiceProvider({ + required VoiceCodecService codec, + required VoicePlayerService player, + }) : _codec = codec, + _player = player { + _playerEventsSub = _player.events.listen((_) { + if (_playingSessionId != null && + !_player.isPlaying && + _player.duration.inMilliseconds > 0 && + _player.position >= _player.duration) { + _playingSessionId = null; + } + notifyListeners(); + }); + _restorePersistedVoiceData(); + } + + String _storageKey() => + ProfileStorageScope.scopedKey(_voiceSessionsStorageKey); + + // ── Session accessors ──────────────────────────────────────────────────── + + VoiceSession? session(String sessionId) => _sessions[sessionId]; + bool isComplete(String sessionId) => + _sessions[sessionId]?.isComplete ?? false; + bool isPlaying(String sessionId) => _playingSessionId == sessionId; + Duration get playbackPosition => _player.position; + Duration get playbackDuration => _player.duration; + + double playbackProgress(String sessionId) { + if (_playingSessionId != sessionId) return 0.0; + final totalMs = _player.duration.inMilliseconds; + if (totalMs <= 0) return 0.0; + final posMs = _player.position.inMilliseconds.clamp(0, totalMs); + return posMs / totalMs; + } + + bool hasOutgoingSession(String sessionId) => + _outgoingSessions.containsKey(sessionId); + Duration? estimateRemainingTransferTime(String sessionId) => + _sessions[sessionId]?.estimateRemaining(); + bool isReceiveCanceled(String sessionId) => + _ignoredIncomingSessions.contains(sessionId); + + List missingPacketIndices(String sessionId) { + final session = _sessions[sessionId]; + if (session == null) return const []; + final missing = []; + for (var i = 0; i < session.total; i++) { + if (session.packets[i] == null) missing.add(i); + } + return missing; + } + + List availablePacketIndices(String sessionId) { + final outgoing = _outgoingSessions[sessionId]; + if (outgoing != null) { + return outgoing.packets.map((packet) => packet.index).toList()..sort(); + } + + final session = _sessions[sessionId]; + if (session == null) return const []; + final indices = []; + for (var i = 0; i < session.packets.length; i++) { + if (session.packets[i] != null) { + indices.add(i); + } + } + return indices; + } + + // ── Packet reception ───────────────────────────────────────────────────── + + /// Add an incoming [packet] to its session. Creates the session on first packet. + /// Returns true if the session just became complete. + bool addPacket(VoicePacket packet) { + if (_ignoredIncomingSessions.contains(packet.sessionId)) { + debugPrint( + '⏹️ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}', + ); + return false; + } + _sessions.putIfAbsent(packet.sessionId, () { + if (packet.total < 1) { + throw StateError( + 'Voice envelope missing for compact packet ${packet.sessionId}', + ); + } + return VoiceSession( + sessionId: packet.sessionId, + mode: packet.mode, + total: packet.total, + ); + }); + + final session = _sessions[packet.sessionId]!; + if (packet.index < session.total) { + final wasMissing = session.packets[packet.index] == null; + session.packets[packet.index] = packet; + if (wasMissing) { + final now = DateTime.now(); + session.firstPacketAt ??= now; + session.lastPacketAt = now; + } + } + + final justComplete = session.isComplete; + _persistVoiceData(); + notifyListeners(); + return justComplete; + } + + void cancelIncomingSession(String sessionId) { + _ignoredIncomingSessions.add(sessionId); + _sessions.remove(sessionId); + if (_playingSessionId == sessionId) { + unawaited(_player.stop()); + _playingSessionId = null; + } + _persistVoiceData(); + notifyListeners(); + } + + void resumeIncomingSession(String sessionId) { + if (_ignoredIncomingSessions.remove(sessionId)) { + notifyListeners(); + } + } + + void registerEnvelope(VoiceEnvelope envelope) { + if (_ignoredIncomingSessions.contains(envelope.sessionId)) { + return; + } + final existing = _sessions[envelope.sessionId]; + if (existing == null) { + _sessions[envelope.sessionId] = VoiceSession( + sessionId: envelope.sessionId, + mode: envelope.mode, + total: envelope.total, + ); + _persistVoiceData(); + notifyListeners(); + return; + } + + final needsMerge = + existing.total != envelope.total || existing.mode != envelope.mode; + if (!needsMerge) { + notifyListeners(); + return; + } + + final merged = VoiceSession( + sessionId: envelope.sessionId, + mode: envelope.mode, + total: envelope.total, + ); + merged.firstPacketAt = existing.firstPacketAt; + merged.lastPacketAt = existing.lastPacketAt; + for (final packet in existing.packets) { + if (packet == null) continue; + if (packet.index < merged.total) { + merged.packets[packet.index] = packet; + } + } + _sessions[envelope.sessionId] = merged; + _persistVoiceData(); + notifyListeners(); + } + + /// Cache encoded packets for deferred voice serving. + void cacheOutgoingSession(String sessionId, List packets) { + if (packets.isEmpty) return; + _outgoingSessions[sessionId] = _OutgoingVoiceSession( + sessionId: sessionId, + packets: List.from(packets), + ); + _persistVoiceData(); + } + + /// Stream a cached voice session to a requester over raw direct packets. + Future serveSessionTo({ + required String sessionId, + required Contact requester, + Set? requestedIndices, + }) async { + final outgoing = _outgoingSessions[sessionId]; + final packets = outgoing != null + ? List.from(outgoing.packets) + : _sessions[sessionId]?.packets.whereType().toList() ?? + const []; + if (packets.isEmpty) { + debugPrint( + '⚠️ [VoiceProvider] No cached or received session for $sessionId', + ); + return false; + } + return serveCachedSessionFragments( + providerLabel: 'VoiceProvider', + sessionId: sessionId, + requester: requester, + fragments: packets, + maxDirectPayloadHops: maxDirectPayloadHops, + indexOf: (packet) => packet.index, + encodeBinary: (packet) => packet.encodeBinary(), + sendRawPacket: sendRawPacketCallback, + requestedIndices: requestedIndices, + ); + } + + // ── Playback ───────────────────────────────────────────────────────────── + + /// Decode and play the voice session with [sessionId]. + /// Plays whatever packets are available (handles partial reception gracefully). + Future play(String sessionId) async { + final session = _sessions[sessionId]; + if (session == null) { + debugPrint( + '❌ [VoiceProvider] play($sessionId) — session not found, known: ${_sessions.keys.toList()}', + ); + return; + } + + debugPrint( + '🎙️ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}', + ); + + try { + final decodedPcm = await _codec.decodePackets( + session.packets, + session.mode, + ); + final pcm = _preparePlaybackPcm(decodedPcm, session.mode); + debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples'); + _playingSessionId = sessionId; + notifyListeners(); + await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz); + } catch (e, st) { + debugPrint('❌ [VoiceProvider] Playback error: $e\n$st'); + if (_playingSessionId == sessionId) { + _playingSessionId = null; + notifyListeners(); + } + } + } + + Future stop() async { + await _player.stop(); + _playingSessionId = null; + notifyListeners(); + } + + Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) { + return pcm; + } + + Future clearStoredVoiceData() async { + await _resetInMemoryState(); + notifyListeners(); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_storageKey()); + } catch (e) { + debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e'); + } + } + + Future reloadProfileScopedState() async { + await _resetInMemoryState(); + await _restorePersistedVoiceData(); + notifyListeners(); + } + + Future _persistVoiceData() async { + try { + final prefs = await SharedPreferences.getInstance(); + final payload = { + 'incoming': _sessions.values + .map( + (session) => { + 'sessionId': session.sessionId, + 'modeId': session.mode.id, + 'total': session.total, + 'packets': session.packets.map((p) => p?.encodeText()).toList(), + }, + ) + .toList(), + 'outgoing': _outgoingSessions.values + .map( + (session) => { + 'sessionId': session.sessionId, + 'packets': session.packets.map((p) => p.encodeText()).toList(), + }, + ) + .toList(), + }; + await prefs.setString(_storageKey(), jsonEncode(payload)); + } catch (e) { + debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e'); + } + } + + Future _restorePersistedVoiceData() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_storageKey()); + if (raw == null || raw.isEmpty) return; + + final parsed = jsonDecode(raw) as Map; + + final incoming = parsed['incoming'] as List? ?? const []; + for (final item in incoming) { + final map = item as Map; + final sessionId = map['sessionId'] as String?; + final modeId = map['modeId'] as int?; + final total = map['total'] as int?; + if (sessionId == null || + modeId == null || + total == null || + total <= 0) { + continue; + } + final mode = VoicePacketMode.fromId(modeId); + final session = VoiceSession( + sessionId: sessionId, + mode: mode, + total: total, + ); + final packets = map['packets'] as List? ?? const []; + for (var i = 0; i < packets.length && i < session.total; i++) { + final encoded = packets[i] as String?; + if (encoded == null || encoded.isEmpty) continue; + final packet = VoicePacket.tryParseText(encoded); + if (packet != null && packet.index < session.total) { + session.packets[packet.index] = packet; + } + } + _sessions[sessionId] = session; + } + + final outgoing = parsed['outgoing'] as List? ?? const []; + for (final item in outgoing) { + final map = item as Map; + final sessionId = map['sessionId'] as String?; + if (sessionId == null || sessionId.isEmpty) continue; + final packetsRaw = map['packets'] as List? ?? const []; + final packets = []; + for (final encoded in packetsRaw) { + final packet = VoicePacket.tryParseText((encoded ?? '') as String); + if (packet != null) packets.add(packet); + } + if (packets.isNotEmpty) { + _outgoingSessions[sessionId] = _OutgoingVoiceSession( + sessionId: sessionId, + packets: packets, + ); + } + } + + notifyListeners(); + debugPrint( + '🎙️ [VoiceProvider] Restored ${_sessions.length} incoming and ${_outgoingSessions.length} outgoing voice sessions', + ); + } catch (e) { + debugPrint('❌ [VoiceProvider] Failed to restore voice data: $e'); + } + } + + Future _resetInMemoryState() async { + if (_playingSessionId != null || _player.isPlaying) { + await _player.stop(); + } + _sessions.clear(); + _outgoingSessions.clear(); + _ignoredIncomingSessions.clear(); + _playingSessionId = null; + } + + @override + void dispose() { + _playerEventsSub.cancel(); + _player.dispose(); + super.dispose(); + } +} + +class _OutgoingVoiceSession { + final String sessionId; + final List packets; + + const _OutgoingVoiceSession({required this.sessionId, required this.packets}); +} diff --git a/lib/screens/add_contact_screen.dart b/lib/screens/add_contact_screen.dart new file mode 100644 index 0000000..67ab716 --- /dev/null +++ b/lib/screens/add_contact_screen.dart @@ -0,0 +1,394 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../providers/connection_provider.dart'; +import '../l10n/app_localizations.dart'; + +class AddContactScreen extends StatefulWidget { + final String? initialAdvert; + + const AddContactScreen({super.key, this.initialAdvert}); + + @override + State createState() => _AddContactScreenState(); +} + +class _AddContactScreenState extends State { + final TextEditingController _advertController = TextEditingController(); + bool _isImporting = false; + bool _importSucceeded = false; + String? _validationError; + + @override + void initState() { + super.initState(); + if (widget.initialAdvert != null && + widget.initialAdvert!.trim().isNotEmpty) { + _advertController.text = widget.initialAdvert!.trim(); + } else { + _loadClipboardIfPresent(); + } + } + + @override + void dispose() { + _advertController.dispose(); + super.dispose(); + } + + Future _loadClipboardIfPresent() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + final text = clipboardData?.text?.trim(); + if (!mounted || text == null || text.isEmpty) { + return; + } + if (_normalizeAdvertText(text) == null) { + return; + } + _advertController.text = text; + } + + String? _normalizeAdvertText(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + + var normalized = trimmed; + if (normalized.startsWith('meshcore://')) { + normalized = normalized.substring('meshcore://'.length); + } + + normalized = normalized.replaceAll(RegExp(r'\s+'), ''); + if (normalized.isEmpty) { + return null; + } + + final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized); + if (!isHex || normalized.length.isOdd) { + return null; + } + + return normalized.toLowerCase(); + } + + Uint8List _hexToBytes(String hex) { + final bytes = []; + for (var i = 0; i < hex.length; i += 2) { + bytes.add(int.parse(hex.substring(i, i + 2), radix: 16)); + } + return Uint8List.fromList(bytes); + } + + Future _pasteFromClipboard() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + final text = clipboardData?.text; + if (text == null || text.trim().isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)), + ); + return; + } + + setState(() { + _advertController.text = text.trim(); + _importSucceeded = false; + _validationError = null; + }); + } + + Future _importContact() async { + final normalized = _normalizeAdvertText(_advertController.text); + if (normalized == null) { + setState(() { + _validationError = + 'Enter a valid meshcore:// advert or raw hexadecimal contact advert.'; + }); + return; + } + + final advertBytes = _hexToBytes(normalized); + if (advertBytes.length < 98) { + setState(() { + _validationError = + 'Advert is too short. Expected exported contact data.'; + }); + return; + } + + setState(() { + _isImporting = true; + _importSucceeded = false; + _validationError = null; + }); + + final connectionProvider = context.read(); + await connectionProvider.importContactAdvert(advertBytes); + final importError = connectionProvider.error; + + if (importError == null) { + await connectionProvider.getContacts(); + } + + if (!mounted) { + return; + } + + setState(() { + _isImporting = false; + }); + + if (importError != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(importError))); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)), + ); + setState(() { + _importSucceeded = true; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final normalized = _normalizeAdvertText(_advertController.text); + final colorScheme = theme.colorScheme; + + return Scaffold( + appBar: AppBar(title: Text(AppLocalizations.of(context)!.addContact)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.person_add_alt_1_outlined, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Import a shared contact advert', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + 'Paste a `meshcore://...` link or raw hexadecimal advert. The app will validate it and import the contact into the connected device.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _ImportHintChip( + icon: Icons.link_outlined, + label: AppLocalizations.of(context)!.acceptsShareLinks, + ), + _ImportHintChip( + icon: Icons.code_outlined, + label: AppLocalizations.of(context)!.supportsRawHex, + ), + _ImportHintChip( + icon: Icons.content_paste_go_outlined, + label: AppLocalizations.of(context)!.clipboardfriendly, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.7), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Advert payload', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + 'You can paste the full share link or only the hex payload.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _advertController, + minLines: 5, + maxLines: 9, + onChanged: (_) { + if (_validationError != null || _importSucceeded) { + setState(() { + _importSucceeded = false; + _validationError = null; + }); + } + }, + decoration: InputDecoration( + labelText: 'Contact advert', + hintText: AppLocalizations.of(context)!.pasteShareLinkOrHexAdvert, + alignLabelWithHint: true, + border: const OutlineInputBorder(), + errorText: _validationError, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + normalized == null + ? 'No valid advert detected yet' + : 'Advert size: ${normalized.length ~/ 2} bytes', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + TextButton.icon( + onPressed: _isImporting ? null : _pasteFromClipboard, + icon: Icon(Icons.content_paste_go_outlined), + label: Text(AppLocalizations.of(context)!.paste), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: (_isImporting || _importSucceeded) + ? null + : _importContact, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + icon: _isImporting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : _importSucceeded + ? const Icon(Icons.check_circle_outline) + : const Icon(Icons.person_add_alt_1_outlined), + label: Text( + _isImporting + ? 'Importing...' + : _importSucceeded + ? 'Imported' + : 'Add Contact', + ), + ), + const SizedBox(height: 12), + if (_importSucceeded) + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.18), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.check_circle, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Contact imported. Paste another advert or edit the current one to import again.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ImportHintChip extends StatelessWidget { + final IconData icon; + final String label; + + const _ImportHintChip({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Text(label, style: theme.textTheme.labelMedium), + ], + ), + ); + } +} diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart new file mode 100644 index 0000000..6919adb --- /dev/null +++ b/lib/screens/contacts_tab.dart @@ -0,0 +1,2758 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import '../l10n/app_localizations.dart'; +import '../models/channel.dart'; +import '../models/contact.dart'; +import '../models/contact_group.dart'; +import '../providers/contacts_provider.dart'; +import '../providers/app_provider.dart'; +import '../providers/connection_provider.dart'; +import '../providers/map_provider.dart'; +import '../providers/messages_provider.dart'; +import '../services/message_destination_preferences.dart'; +import '../utils/contact_grouping.dart'; +import '../utils/avatar_label_helper.dart'; +import '../widgets/common/contact_avatar.dart'; +import '../widgets/contacts/contact_tile.dart'; +import '../widgets/contacts/add_channel_dialog.dart'; +import '../services/region_scope_preferences.dart'; +import '../utils/toast_logger.dart'; +import 'add_contact_screen.dart'; + +class ContactsTab extends StatefulWidget { + final VoidCallback? onNavigateToMap; + final VoidCallback? onNavigateToMessages; + + const ContactsTab({ + super.key, + this.onNavigateToMap, + this.onNavigateToMessages, + }); + + @override + State createState() => _ContactsTabState(); +} + +class _ContactsTabState extends State { + static const bool _isScreenshotRun = bool.fromEnvironment( + 'MESHCORE_SCREENSHOTS', + ); + Position? _currentPosition; + final Map _sectionFilters = { + ContactSection.teamMembers: '', + ContactSection.repeaters: '', + ContactSection.sensors: '', + ContactSection.rooms: '', + ContactSection.channels: '', + }; + late final Map _filterControllers; + final Map _sortModes = { + ContactSection.teamMembers: ContactSortMode.lastSeen, + ContactSection.repeaters: ContactSortMode.lastSeen, + ContactSection.sensors: ContactSortMode.lastSeen, + ContactSection.rooms: ContactSortMode.lastSeen, + ContactSection.channels: ContactSortMode.alphabetical, + }; + + @override + void initState() { + super.initState(); + _filterControllers = { + for (final section in ContactSection.values) + section: TextEditingController(text: _sectionFilters[section] ?? ''), + }; + if (!_isScreenshotRun) { + _getCurrentLocation(); + } + // Mark all contacts as viewed when tab is opened + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().markAllAsViewed(); + }); + } + + @override + void dispose() { + for (final controller in _filterControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + Future _getCurrentLocation() async { + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 0, + ), + ); + if (mounted) { + setState(() { + _currentPosition = position; + }); + } + } catch (e) { + // Silently fail if location not available + debugPrint('Failed to get location: $e'); + } + } + + Future _handleRefresh() async { + if (!mounted) return; + final appProvider = context.read(); + await appProvider.refresh(); + // Also refresh location + if (!mounted) return; + await _getCurrentLocation(); + } + + /// Calculate distance between two points in meters + double _calculateDistanceInMeters( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + final a = + sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + /// Format distance for display + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else if (meters < 10000) { + return '${(meters / 1000).toStringAsFixed(2)}km'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } + + List _filterContactsForSection( + List contacts, + ContactSection section, + ) { + final query = (_sectionFilters[section] ?? '').trim().toLowerCase(); + if (query.isEmpty) { + return contacts; + } + + return contacts.where((contact) { + final name = contact.displayName.toLowerCase(); + final advertisedName = contact.advName.toLowerCase(); + return name.contains(query) || + advertisedName.contains(query) || + ContactGrouping.contactMatchesInferredGroupLabel(contact, query); + }).toList(); + } + + bool _contactMatchesFilter(Contact contact, String query) { + final normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.isEmpty) { + return true; + } + + final name = contact.displayName.toLowerCase(); + final advertisedName = contact.advName.toLowerCase(); + return name.contains(normalizedQuery) || + advertisedName.contains(normalizedQuery) || + ContactGrouping.contactMatchesInferredGroupLabel( + contact, + normalizedQuery, + ); + } + + bool _sectionHasActiveFilter(ContactSection section) { + return (_sectionFilters[section] ?? '').trim().isNotEmpty; + } + + bool _showSavedGroupsForSection(ContactSection section) { + return !_sectionHasActiveFilter(section); + } + + List<_RenderedSavedGroup> _buildSavedGroupsForSection( + ContactsProvider contactsProvider, + List contacts, + ContactSection section, + ) { + return contactsProvider + .savedGroupsForSection(section.name) + .map((group) { + final matches = contacts + .where((contact) => _contactMatchesSavedGroup(contact, group)) + .toList(); + return _RenderedSavedGroup(group: group, contacts: matches); + }) + .where((group) => group.contacts.isNotEmpty) + .toList() + ..sort( + (a, b) => b.contacts.first.lastSeenTime.compareTo( + a.contacts.first.lastSeenTime, + ), + ); + } + + bool _contactMatchesSavedGroup(Contact contact, SavedContactGroup group) { + final matchPrefixes = group.matchPrefixes; + if (matchPrefixes != null && matchPrefixes.isNotEmpty) { + final inferredLabel = ContactGrouping.inferredGroupLabelForContact( + contact, + )?.toLowerCase(); + if (inferredLabel == null) { + return false; + } + return matchPrefixes.any( + (prefix) => prefix.toLowerCase() == inferredLabel, + ); + } + + return _contactMatchesFilter(contact, group.query); + } + + Future _toggleSavedGroupForSection( + BuildContext context, + ContactsProvider contactsProvider, + ContactSection section, + ) async { + final filter = (_sectionFilters[section] ?? '').trim(); + if (filter.isEmpty) { + return; + } + + final alreadySaved = contactsProvider.hasSavedGroupForFilter( + section.name, + filter, + ); + + if (alreadySaved) { + await contactsProvider.removeSavedGroupForFilter(section.name, filter); + } else { + await contactsProvider.addSavedGroupForFilter( + section.name, + filter, + label: filter, + ); + } + + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + alreadySaved + ? 'Removed saved group "$filter"' + : 'Saved group "$filter"', + ), + ), + ); + } + + Future _createAutoGroupsForSection( + BuildContext context, + ContactsProvider contactsProvider, + ContactSection section, + List contacts, { + int? maxNamedGroups, + String? overflowGroupLabel, + String emptyMessage = 'No auto groups available', + String successMessage = 'Updated auto groups', + }) async { + final inferredGroups = ContactGrouping.inferGroups( + contacts, + maxNamedGroups: maxNamedGroups, + overflowGroupLabel: overflowGroupLabel, + ); + + final now = DateTime.now(); + final groups = inferredGroups + .map( + (group) => SavedContactGroup( + id: '${ContactsProvider.autoGroupIdPrefix}${section.name}_${group.key}', + sectionKey: section.name, + label: group.label, + query: group.label, + createdAt: now, + matchPrefixes: group.matchPrefixes, + isAutoGroup: true, + ), + ) + .toList(); + + await contactsProvider.replaceAutoGroupsForSection(section.name, groups); + + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(groups.isEmpty ? emptyMessage : successMessage)), + ); + } + + List _sortContacts(List contacts, ContactSection section) { + final sorted = List.from(contacts); + final sortMode = + _sortModes[section] ?? + (section == ContactSection.channels + ? ContactSortMode.alphabetical + : ContactSortMode.lastSeen); + + sorted.sort((a, b) { + if (sortMode == ContactSortMode.alphabetical) { + return a.displayName.toLowerCase().compareTo( + b.displayName.toLowerCase(), + ); + } + + if (sortMode == ContactSortMode.distance) { + final distanceA = _distanceFromCurrentPosition(a); + final distanceB = _distanceFromCurrentPosition(b); + + if (distanceA != null && distanceB != null) { + final distanceCompare = distanceA.compareTo(distanceB); + if (distanceCompare != 0) return distanceCompare; + } else if (distanceA != null) { + return -1; + } else if (distanceB != null) { + return 1; + } + } + + final lastSeenCompare = b.lastSeenTime.compareTo(a.lastSeenTime); + if (lastSeenCompare != 0) { + return lastSeenCompare; + } + + return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); + }); + + return sorted; + } + + double? _distanceFromCurrentPosition(Contact contact) { + final currentPosition = _currentPosition; + final contactLocation = contact.displayLocation; + if (currentPosition == null || contactLocation == null) { + return null; + } + + return _calculateDistanceInMeters( + currentPosition.latitude, + currentPosition.longitude, + contactLocation.latitude, + contactLocation.longitude, + ); + } + + /// Show the add channel sheet + Future _showAddChannelDialog(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: AddChannelSheet( + onCreateChannel: (name, secret) async { + final connectionProvider = context.read(); + try { + await connectionProvider.createChannel( + channelName: name, + channelSecret: secret, + ); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelCreatedSuccessfully), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelCreationFailed(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + rethrow; // Re-throw to let dialog handle the error state + } + }, + ), + ), + ), + ); + } + + Future _openAddContactScreen(BuildContext context) async { + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => const AddContactScreen())); + } + + Future _showDeleteChannelDialog( + BuildContext context, + Contact channel, + ) async { + if (channel.isPublicChannel) { + return; + } + + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.deleteChannel), + content: Text(l10n.deleteChannelConfirmation(channel.advName)), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(l10n.delete), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) { + return; + } + + try { + final channelIdx = channel.publicKey.length > 1 + ? channel.publicKey[1] + : 0; + await context.read().deleteChannel(channelIdx); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelDeletedSuccessfully), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelDeletionFailed(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _openMessagesForChannel( + BuildContext context, + Contact channel, + ) async { + final messagesProvider = context.read(); + await MessageDestinationPreferences.setDestination( + MessageDestinationPreferences.destinationTypeChannel, + recipientPublicKey: channel.publicKeyHex, + ); + messagesProvider.navigateToDestination( + MessageDestinationPreferences.destinationTypeChannel, + recipientPublicKeyHex: channel.publicKeyHex, + ); + widget.onNavigateToMessages?.call(); + } + + void _showChannelOnMap(BuildContext context, Contact channel) { + final location = channel.displayLocation; + if (location == null) { + return; + } + + context.read().navigateToLocation( + location: LatLng(location.latitude, location.longitude), + ); + widget.onNavigateToMap?.call(); + } + + Future _exportHashChannelPskBase64( + BuildContext context, + Contact channel, + ) async { + final channelName = channel.advName.trim(); + if (!Channel.isHashChannelName(channelName)) { + return; + } + + final messenger = ScaffoldMessenger.of(context); + final copiedMessage = AppLocalizations.of( + context, + )!.copiedToClipboard('psk_base64'); + + await Clipboard.setData( + ClipboardData(text: Channel.pskBase64ForHashChannelName(channelName)), + ); + if (!mounted) { + return; + } + + messenger.showSnackBar(SnackBar(content: Text(copiedMessage))); + } + + String _locationSharingErrorMessage(Object error) { + final message = error.toString(); + if (message.startsWith('Bad state: ')) { + return message.substring('Bad state: '.length); + } + return message; + } + + Future _setChannelLocationSharing( + BuildContext context, + Contact channel, + bool enabled, + ) async { + final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + try { + final result = await context + .read() + .setChannelLocationSharingEnabled( + channelIdx, + enabled, + l10n: AppLocalizations.of(context), + ); + if (!context.mounted) return; + ToastLogger.success(context, result.message); + } catch (error) { + if (!context.mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + + Future _handleChannelLocationSharingAction( + BuildContext context, + Contact channel, + ) async { + if (channel.isPublicChannel) { + if (!context.mounted) return; + ToastLogger.error( + context, + 'Select a private channel to share your location.', + ); + return; + } + + final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + if (channelIdx <= 0) { + if (!context.mounted) return; + ToastLogger.error( + context, + 'Select a private channel to share your location.', + ); + return; + } + + try { + final sharingState = await context + .read() + .getChannelLocationSharingState(channelIdx); + if (!context.mounted) return; + await _setChannelLocationSharing( + context, + channel, + !sharingState.isSharing, + ); + } catch (error) { + if (!context.mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + + void _showChannelActionSheet(BuildContext context, Contact channel) { + final l10n = AppLocalizations.of(context)!; + final canExportHashChannelPsk = Channel.isHashChannelName( + channel.advName.trim(), + ); + final channelLocationSharingFuture = + !channel.isPublicChannel && channel.publicKey.length > 1 + ? context.read().getChannelLocationSharingState( + channel.publicKey[1], + ) + : null; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + List<_ChannelSheetAction> buildActions( + ChannelLocationSharingState? sharingState, + ) { + return <_ChannelSheetAction>[ + _ChannelSheetAction( + icon: Icons.message_outlined, + label: l10n.messages, + onTap: () async { + Navigator.pop(context); + await _openMessagesForChannel(context, channel); + }, + ), + if (!channel.isPublicChannel) + _ChannelSheetAction( + icon: sharingState?.isSharing == true + ? Icons.location_off_rounded + : Icons.share_location_rounded, + label: sharingState?.isSharing == true + ? 'Stop sharing my location' + : 'Share my location', + onTap: () async { + Navigator.pop(context); + await _handleChannelLocationSharingAction(context, channel); + }, + ), + if (channel.displayLocation != null) + _ChannelSheetAction( + icon: Icons.map_outlined, + label: l10n.viewOnMap, + onTap: () async { + Navigator.pop(context); + _showChannelOnMap(context, channel); + }, + ), + if (canExportHashChannelPsk) + _ChannelSheetAction( + icon: Icons.key_outlined, + label: '${l10n.exportToClipboard} psk_base64', + onTap: () async { + Navigator.pop(context); + await _exportHashChannelPskBase64(context, channel); + }, + ), + _ChannelSheetAction( + icon: Icons.language_rounded, + label: l10n.setRegionScope, + onTap: () async { + Navigator.pop(context); + if (!context.mounted) return; + _showRegionScopeForChannel(context, channel); + }, + ), + if (!channel.isPublicChannel) + _ChannelSheetAction( + icon: Icons.delete_outline_rounded, + label: l10n.deleteChannel, + destructive: true, + onTap: () async { + Navigator.pop(context); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + _showDeleteChannelDialog(context, channel); + }); + }, + ), + ]; + } + + Widget buildSheet(ChannelLocationSharingState? sharingState) { + return _ChannelActionSheet( + channel: channel, + actions: buildActions(sharingState), + onClose: () => Navigator.pop(sheetContext), + ); + } + + if (channelLocationSharingFuture == null) { + return buildSheet(null); + } + + return FutureBuilder( + future: channelLocationSharingFuture, + builder: (context, snapshot) { + return buildSheet(snapshot.data); + }, + ); + }, + ); + } + + void _showRegionScopeForChannel(BuildContext context, Contact channel) async { + final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + final l10n = AppLocalizations.of(context)!; + final currentScope = await RegionScopePreferences.getScope(channelIdx); + if (!context.mounted) return; + + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (sheetContext) { + return _ContactsRegionScopeSheet( + currentScopeName: currentScope?.name, + l10n: l10n, + onScopeSelected: (String? name) async { + Navigator.of(sheetContext).pop(); + if (name == null) { + await RegionScopePreferences.clearScope(channelIdx); + if (!context.mounted) return; + ToastLogger.success(context, l10n.regionScopeCleared); + } else { + final key = RegionScopePreferences.deriveRegionKey(name); + await RegionScopePreferences.setScope(channelIdx, name, key); + if (!context.mounted) return; + ToastLogger.success(context, l10n.regionScopeSet(name)); + } + }, + ); + }, + ); + } + + Color _sectionAccentColor(BuildContext context, ContactSection section) { + final colorScheme = Theme.of(context).colorScheme; + switch (section) { + case ContactSection.teamMembers: + return colorScheme.primary; + case ContactSection.repeaters: + return colorScheme.tertiary; + case ContactSection.sensors: + return colorScheme.secondary; + case ContactSection.rooms: + return colorScheme.primary; + case ContactSection.channels: + return Color.alphaBlend( + colorScheme.tertiary.withValues(alpha: 0.65), + colorScheme.primary.withValues(alpha: 0.35), + ); + } + } + + IconData _sortModeIcon(ContactSortMode mode) { + switch (mode) { + case ContactSortMode.lastSeen: + return Icons.schedule_rounded; + case ContactSortMode.distance: + return Icons.near_me_rounded; + case ContactSortMode.alphabetical: + return Icons.sort_by_alpha_rounded; + } + } + + String _sortModeLabel(AppLocalizations l10n, ContactSortMode mode) { + switch (mode) { + case ContactSortMode.lastSeen: + return l10n.lastSeen; + case ContactSortMode.distance: + return l10n.distance; + case ContactSortMode.alphabetical: + return 'A-Z'; + } + } + + List _availableSortModes(ContactSection section) { + switch (section) { + case ContactSection.channels: + return const [ContactSortMode.alphabetical, ContactSortMode.lastSeen]; + default: + return const [ + ContactSortMode.lastSeen, + ContactSortMode.distance, + ContactSortMode.alphabetical, + ]; + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + body: Consumer( + builder: (context, contactsProvider, child) { + final colorScheme = Theme.of(context).colorScheme; + final appProvider = context.watch(); + final messagesProvider = context.watch(); + final connectionProvider = context.watch(); + final allChatContacts = _sortContacts( + contactsProvider.chatContacts, + ContactSection.teamMembers, + ); + final allRepeaters = _sortContacts( + contactsProvider.repeaters, + ContactSection.repeaters, + ); + final allSensors = _sortContacts( + contactsProvider.sensorContacts, + ContactSection.sensors, + ); + final allRooms = _sortContacts( + contactsProvider.rooms, + ContactSection.rooms, + ); + final allChannels = _sortContacts( + contactsProvider.channels, + ContactSection.channels, + ); + final chatContacts = _filterContactsForSection( + allChatContacts, + ContactSection.teamMembers, + ); + final savedTeamGroups = _buildSavedGroupsForSection( + contactsProvider, + allChatContacts, + ContactSection.teamMembers, + ); + final visibleSavedTeamGroups = + _showSavedGroupsForSection(ContactSection.teamMembers) + ? savedTeamGroups + : const <_RenderedSavedGroup>[]; + final repeaters = _filterContactsForSection( + allRepeaters, + ContactSection.repeaters, + ); + final savedRepeaterGroups = _buildSavedGroupsForSection( + contactsProvider, + allRepeaters, + ContactSection.repeaters, + ); + final visibleSavedRepeaterGroups = + _showSavedGroupsForSection(ContactSection.repeaters) + ? savedRepeaterGroups + : const <_RenderedSavedGroup>[]; + final ungroupedRepeaters = _excludeGroupedContacts( + repeaters, + visibleSavedRepeaterGroups, + ); + final showRepeatersOthersGroup = + visibleSavedRepeaterGroups.length > 1 && + ungroupedRepeaters.isNotEmpty; + final sensors = _filterContactsForSection( + allSensors, + ContactSection.sensors, + ); + final savedSensorGroups = _buildSavedGroupsForSection( + contactsProvider, + allSensors, + ContactSection.sensors, + ); + final visibleSavedSensorGroups = + _showSavedGroupsForSection(ContactSection.sensors) + ? savedSensorGroups + : const <_RenderedSavedGroup>[]; + final rooms = _filterContactsForSection( + allRooms, + ContactSection.rooms, + ); + final savedRoomGroups = _buildSavedGroupsForSection( + contactsProvider, + allRooms, + ContactSection.rooms, + ); + final visibleSavedRoomGroups = + _showSavedGroupsForSection(ContactSection.rooms) + ? savedRoomGroups + : const <_RenderedSavedGroup>[]; + final filteredChannels = _filterContactsForSection( + allChannels, + ContactSection.channels, + ); + final savedChannelGroups = _buildSavedGroupsForSection( + contactsProvider, + allChannels, + ContactSection.channels, + ); + final visibleSavedChannelGroups = + _showSavedGroupsForSection(ContactSection.channels) + ? savedChannelGroups + : const <_RenderedSavedGroup>[]; + final showFavouritesSection = + appProvider.isContactsSectionEnabled( + ContactsTabSection.favourites, + ) && + contactsProvider.favouriteContacts.isNotEmpty; + final showTeamMembersSection = + appProvider.isContactsSectionEnabled( + ContactsTabSection.teamMembers, + ) && + allChatContacts.isNotEmpty; + final showRepeatersSection = + appProvider.isContactsSectionEnabled( + ContactsTabSection.repeaters, + ) && + allRepeaters.isNotEmpty; + final showSensorsSection = + appProvider.isContactsSectionEnabled( + ContactsTabSection.sensors, + ) && + allSensors.isNotEmpty; + final showRoomsSection = + appProvider.isContactsSectionEnabled(ContactsTabSection.rooms) && + allRooms.isNotEmpty; + final showChannelsSection = + appProvider.isContactsSectionEnabled( + ContactsTabSection.channels, + ) && + allChannels.isNotEmpty; + final hasAnyContactData = + allChatContacts.isNotEmpty || + allRepeaters.isNotEmpty || + allSensors.isNotEmpty || + allRooms.isNotEmpty || + allChannels.isNotEmpty; + final hasAnyVisibleSection = + showFavouritesSection || + showTeamMembersSection || + showRepeatersSection || + showSensorsSection || + showRoomsSection || + showChannelsSection; + + if (!hasAnyContactData) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.contacts_outlined, + size: 64, + color: Theme.of(context).disabledColor, + ), + SizedBox(height: 16), + Text( + l10n.noContactsYet, + style: Theme.of(context).textTheme.titleLarge, + ), + SizedBox(height: 8), + Text( + l10n.connectToDeviceToLoadContacts, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + if (connectionProvider.deviceInfo.isConnected) + Padding( + padding: const EdgeInsets.only(top: 16), + child: OutlinedButton.icon( + onPressed: () => _openAddContactScreen(context), + icon: Icon(Icons.person_add_alt_1_outlined), + label: Text(l10n.addContact), + ), + ), + ], + ), + ); + } + + if (!hasAnyVisibleSection) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.tune_rounded, + size: 56, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + 'All contacts sections are hidden', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Enable one or more sections in Settings to show contacts here.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 24), + children: [ + if (showFavouritesSection) + _SectionCard( + accentColor: Colors.amber, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.favourites, + count: contactsProvider.favouriteContacts.length, + icon: Icons.star_rounded, + accentColor: Colors.amber, + ), + ..._buildContactSectionItems( + contactsProvider.favouriteContacts, + ), + ], + ), + ), + + if (showTeamMembersSection) + _SectionCard( + accentColor: _sectionAccentColor( + context, + ContactSection.teamMembers, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.teamMembers, + count: chatContacts.length, + icon: Icons.people_alt_rounded, + accentColor: _sectionAccentColor( + context, + ContactSection.teamMembers, + ), + ), + _buildSectionFilterField( + context, + ContactSection.teamMembers, + contactsProvider, + onSecondaryAction: () => _createAutoGroupsForSection( + context, + contactsProvider, + ContactSection.teamMembers, + allChatContacts, + emptyMessage: 'No contact auto groups available', + successMessage: 'Updated contact auto groups', + ), + secondaryActionIcon: Icons.auto_awesome_outlined, + secondaryActionTooltip: 'Auto group', + ), + ..._buildSavedGroupCards( + visibleSavedTeamGroups, + ContactSection.teamMembers, + ), + if (chatContacts.isEmpty && + _sectionHasActiveFilter(ContactSection.teamMembers)) + _buildNoFilterResults(context) + else + ..._buildContactSectionItems( + _excludeGroupedContacts( + chatContacts, + visibleSavedTeamGroups, + ), + ), + ], + ), + ), + + if (showRepeatersSection) + _SectionCard( + accentColor: _sectionAccentColor( + context, + ContactSection.repeaters, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.repeaters, + count: repeaters.length, + icon: Icons.router_rounded, + accentColor: _sectionAccentColor( + context, + ContactSection.repeaters, + ), + ), + _buildSectionFilterField( + context, + ContactSection.repeaters, + contactsProvider, + onSecondaryAction: () => _createAutoGroupsForSection( + context, + contactsProvider, + ContactSection.repeaters, + allRepeaters, + maxNamedGroups: 2, + overflowGroupLabel: 'Others', + emptyMessage: 'No repeater auto groups available', + successMessage: 'Updated repeater auto groups', + ), + secondaryActionIcon: Icons.auto_awesome_outlined, + secondaryActionTooltip: 'Auto group', + ), + ..._buildSavedGroupCards( + visibleSavedRepeaterGroups, + ContactSection.repeaters, + ), + if (repeaters.isEmpty && + _sectionHasActiveFilter(ContactSection.repeaters)) + _buildNoFilterResults(context) + else if (showRepeatersOthersGroup) + _InferredContactGroupCard( + label: l10n.others, + contacts: ungroupedRepeaters, + compactContacts: true, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, + ) + else + ..._buildContactSectionItems( + ungroupedRepeaters, + compact: true, + ), + ], + ), + ), + + if (showSensorsSection) + _SectionCard( + accentColor: _sectionAccentColor( + context, + ContactSection.sensors, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.sensors, + count: sensors.length, + icon: Icons.sensors_rounded, + accentColor: _sectionAccentColor( + context, + ContactSection.sensors, + ), + ), + _buildSectionFilterField( + context, + ContactSection.sensors, + contactsProvider, + ), + ..._buildSavedGroupCards( + visibleSavedSensorGroups, + ContactSection.sensors, + ), + if (sensors.isEmpty && + _sectionHasActiveFilter(ContactSection.sensors)) + _buildNoFilterResults(context) + else + ..._buildContactSectionItems( + _excludeGroupedContacts( + sensors, + visibleSavedSensorGroups, + ), + ), + ], + ), + ), + + if (showRoomsSection) + _SectionCard( + accentColor: _sectionAccentColor( + context, + ContactSection.rooms, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.rooms, + count: rooms.length, + icon: Icons.meeting_room_outlined, + accentColor: _sectionAccentColor( + context, + ContactSection.rooms, + ), + ), + _buildSectionFilterField( + context, + ContactSection.rooms, + contactsProvider, + ), + ..._buildSavedGroupCards( + visibleSavedRoomGroups, + ContactSection.rooms, + ), + if (rooms.isEmpty && + _sectionHasActiveFilter(ContactSection.rooms)) + _buildNoFilterResults(context) + else + ..._buildContactSectionItems( + _excludeGroupedContacts( + rooms, + visibleSavedRoomGroups, + ), + ), + ], + ), + ), + + if (showChannelsSection) + _SectionCard( + accentColor: _sectionAccentColor( + context, + ContactSection.channels, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + title: l10n.channels, + count: filteredChannels.length, + icon: Icons.broadcast_on_personal_rounded, + accentColor: _sectionAccentColor( + context, + ContactSection.channels, + ), + ), + _buildSectionFilterField( + context, + ContactSection.channels, + contactsProvider, + ), + ..._buildSavedGroupCards( + visibleSavedChannelGroups, + ContactSection.channels, + ), + if (filteredChannels.isEmpty && + _sectionHasActiveFilter(ContactSection.channels)) + _buildNoFilterResults(context) + else + ..._excludeGroupedContacts( + filteredChannels, + visibleSavedChannelGroups, + ).map( + (channel) => _ChannelActivityCard( + channel: channel, + messagesProvider: messagesProvider, + contactsProvider: contactsProvider, + onTap: () => + _showChannelActionSheet(context, channel), + ), + ), + ], + ), + ), + + if (connectionProvider.deviceInfo.isConnected) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: 0.35, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: FilledButton.tonalIcon( + onPressed: () => _openAddContactScreen(context), + icon: const Icon(Icons.person_add_alt_1_outlined), + label: Text(l10n.addContact), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.tonalIcon( + onPressed: () => _showAddChannelDialog(context), + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.addChannel), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: _sectionAccentColor( + context, + ContactSection.channels, + ).withValues(alpha: 0.14), + foregroundColor: _sectionAccentColor( + context, + ContactSection.channels, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } + + 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, + ), + ), + ); + } + + List _buildContactSectionItems( + List contacts, { + bool compact = false, + }) { + return contacts + .map( + (contact) => ContactTile( + contact: contact, + compact: compact, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, + ), + ) + .toList(); + } + + List _excludeGroupedContacts( + List contacts, + List<_RenderedSavedGroup> savedGroups, + ) { + final groupedKeys = savedGroups + .expand( + (group) => group.contacts.map((contact) => contact.publicKeyHex), + ) + .toSet(); + return contacts + .where((contact) => !groupedKeys.contains(contact.publicKeyHex)) + .toList(); + } + + List _buildSavedGroupCards( + List<_RenderedSavedGroup> groups, + ContactSection section, + ) { + return groups + .map( + (group) => _InferredContactGroupCard( + label: group.group.label, + contacts: group.contacts, + compactContacts: section == ContactSection.repeaters, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, + onDelete: () => context + .read() + .removeSavedGroupById(group.group.id), + ), + ) + .toList(); + } + + Widget _buildSectionFilterField( + BuildContext context, + ContactSection section, + ContactsProvider contactsProvider, { + VoidCallback? onSecondaryAction, + IconData? secondaryActionIcon, + String? secondaryActionTooltip, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final controller = _filterControllers[section]!; + final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty; + final isSavedFilter = hasFilter + ? contactsProvider.hasSavedGroupForFilter( + section.name, + _sectionFilters[section] ?? '', + ) + : false; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: 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: TextFormField( + controller: controller, + onChanged: (value) { + setState(() { + _sectionFilters[section] = value; + }); + }, + cursorColor: colorScheme.primary, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + height: 1.1, + ), + decoration: InputDecoration( + hintText: AppLocalizations.of( + context, + )!.searchThisSection, + 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, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 4), + child: _buildSortMenu(context, section, compact: true), + ), + if (hasFilter) ...[ + if (onSecondaryAction != null && + secondaryActionIcon != null) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Tooltip( + message: secondaryActionTooltip ?? '', + child: Material( + color: colorScheme.tertiary.withValues(alpha: 0.10), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onSecondaryAction, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + secondaryActionIcon, + size: 16, + color: colorScheme.tertiary, + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 4), + child: Material( + color: + (isSavedFilter + ? colorScheme.error + : colorScheme.primary) + .withValues(alpha: 0.10), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => _toggleSavedGroupForSection( + context, + contactsProvider, + section, + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + isSavedFilter + ? Icons.delete_outline_rounded + : Icons.bookmark_add_outlined, + size: 16, + color: isSavedFilter + ? colorScheme.error + : colorScheme.primary, + ), + ), + ), + ), + ), + 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: () { + controller.clear(); + setState(() { + _sectionFilters[section] = ''; + }); + }, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + Icons.close_rounded, + size: 14, + color: colorScheme.primary, + ), + ), + ), + ), + ), + ] else + Row( + children: [ + if (onSecondaryAction != null && + secondaryActionIcon != null) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Tooltip( + message: secondaryActionTooltip ?? '', + child: Material( + color: colorScheme.tertiary.withValues( + alpha: 0.10, + ), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onSecondaryAction, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + secondaryActionIcon, + size: 16, + color: colorScheme.tertiary, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildSortMenu( + BuildContext context, + ContactSection section, { + bool compact = false, + }) { + final l10n = AppLocalizations.of(context)!; + final selectedMode = + _sortModes[section] ?? + (section == ContactSection.channels + ? ContactSortMode.alphabetical + : ContactSortMode.lastSeen); + final colorScheme = Theme.of(context).colorScheme; + final availableModes = _availableSortModes(section); + + return PopupMenuButton( + tooltip: AppLocalizations.of(context)!.sort, + initialValue: selectedMode, + onSelected: (sortMode) { + setState(() { + _sortModes[section] = sortMode; + }); + }, + itemBuilder: (context) => availableModes + .map( + (mode) => PopupMenuItem( + value: mode, + child: Row( + children: [ + Icon( + _sortModeIcon(mode), + size: 18, + color: selectedMode == mode ? colorScheme.primary : null, + ), + const SizedBox(width: 8), + Text(_sortModeLabel(l10n, mode)), + ], + ), + ), + ) + .toList(), + child: compact + ? Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon( + _sortModeIcon(selectedMode), + size: 18, + color: colorScheme.primary, + ), + ) + : Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.38), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _sortModeIcon(selectedMode), + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + _sortModeLabel(l10n, selectedMode), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.expand_more_rounded, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + +enum ContactSortMode { lastSeen, distance, alphabetical } + +enum ContactSection { teamMembers, repeaters, sensors, rooms, channels } + +class _RenderedSavedGroup { + final SavedContactGroup group; + final List contacts; + + const _RenderedSavedGroup({required this.group, required this.contacts}); +} + +class _SectionHeader extends StatelessWidget { + final String title; + final int count; + final IconData icon; + final Color accentColor; + + const _SectionHeader({ + required this.title, + required this.count, + required this.icon, + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final titleBlock = Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: accentColor.withValues(alpha: 0.18)), + ), + child: Text( + count.toString(), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: accentColor, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: accentColor.withValues(alpha: 0.18)), + ), + alignment: Alignment.center, + child: Icon(icon, size: 20, color: accentColor), + ), + const SizedBox(width: 12), + Expanded(child: titleBlock), + ], + ), + ); + } +} + +class _SectionCard extends StatelessWidget { + final Color accentColor; + final Widget child; + + const _SectionCard({required this.accentColor, required this.child}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(26), + border: Border.all(color: accentColor.withValues(alpha: 0.14)), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.04), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: child, + ); + } +} + +class _InferredContactGroupCard extends StatelessWidget { + final String label; + final List contacts; + final bool compactContacts; + final Position? currentPosition; + final double Function(double, double, double, double) calculateDistance; + final String Function(double) formatDistance; + final VoidCallback? onNavigateToMap; + final VoidCallback? onNavigateToMessages; + final VoidCallback? onDelete; + + const _InferredContactGroupCard({ + required this.label, + required this.contacts, + this.compactContacts = false, + required this.currentPosition, + required this.calculateDistance, + required this.formatDistance, + required this.onNavigateToMap, + required this.onNavigateToMessages, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.only(bottom: 10), + child: Material( + color: colorScheme.surfaceContainerLow, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 0, + ), + childrenPadding: const EdgeInsets.fromLTRB(10, 0, 10, 6), + minTileHeight: 44, + initiallyExpanded: false, + leading: Icon( + Icons.folder_copy_outlined, + size: 16, + color: colorScheme.primary, + ), + title: Row( + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + fontSize: 13, + ), + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 1, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + contacts.length.toString(), + style: Theme.of(context).textTheme.labelSmall, + ), + ), + if (onDelete != null) ...[ + const SizedBox(width: 2), + IconButton( + tooltip: AppLocalizations.of(context)!.deleteGroup, + onPressed: onDelete, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: 32, + height: 32, + ), + padding: EdgeInsets.zero, + icon: Icon( + Icons.delete_outline_rounded, + size: 16, + color: colorScheme.error, + ), + ), + ], + ], + ), + children: [ + ...contacts.map( + (contact) => ContactTile( + contact: contact, + compact: compactContacts, + currentPosition: currentPosition, + calculateDistance: calculateDistance, + formatDistance: formatDistance, + onNavigateToMap: onNavigateToMap, + onNavigateToMessages: onNavigateToMessages, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ChannelActivityCard extends StatelessWidget { + final Contact channel; + final MessagesProvider messagesProvider; + final ContactsProvider contactsProvider; + final VoidCallback? onTap; + + const _ChannelActivityCard({ + required this.channel, + required this.messagesProvider, + required this.contactsProvider, + this.onTap, + }); + + String _formatRelativeTime(BuildContext context, DateTime when) { + final l10n = AppLocalizations.of(context)!; + final diff = DateTime.now().difference(when); + + if (diff.inMinutes < 1) return l10n.justNow; + if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); + if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours); + return l10n.daysAgo(diff.inDays); + } + + Widget _buildChannelLocationSharingMarker( + BuildContext context, + ChannelLocationSharingMode mode, + ) { + const accent = Color(0xFF16A34A); + + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: Icon(Icons.location_pin, size: 11, color: Colors.white), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + ); + final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + final channelLocationSharingMode = context + .watch() + .channelLocationSharingModeForChannel(channelIdx); + final channelMessages = messagesProvider.getMessagesForChannel(channelIdx) + ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + final lastActivityAt = messagesProvider.getLastActivityForDestination( + channel, + ); + final activityLabel = lastActivityAt == null + ? null + : _formatRelativeTime(context, lastActivityAt); + final participantNames = []; + for (final message in channelMessages) { + final senderName = message.senderName?.trim(); + if (senderName == null || senderName.isEmpty) continue; + if (!participantNames.contains(senderName)) { + participantNames.add(senderName); + } + } + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: colorScheme.surfaceContainerLow, + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.045), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + ContactAvatar(contact: channel, radius: 24), + if (channelLocationSharingMode != null) + Positioned( + right: -2, + bottom: -2, + child: _buildChannelLocationSharingMarker( + context, + channelLocationSharingMode, + ), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + channel.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + ), + const SizedBox(width: 8), + if (activityLabel != null) + Text( + activityLabel, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 8), + if (participantNames.isNotEmpty) + _ExpandableParticipantStack( + names: participantNames, + contactForName: _findParticipantContact, + ) + else + Text( + 'No recent chatters', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _MetricChip( + icon: Icons.forum_outlined, + label: '${channelMessages.length}', + helper: 'messages', + ), + _MetricChip( + icon: Icons.group_outlined, + label: '${participantNames.length}', + helper: 'active', + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Contact? _findParticipantContact(String name) { + for (final contact in contactsProvider.contacts) { + if (!contact.isChannel && contact.advName == name) { + return contact; + } + } + return null; + } +} + +class _ExpandableParticipantStack extends StatefulWidget { + final List names; + final Contact? Function(String name) contactForName; + + const _ExpandableParticipantStack({ + required this.names, + required this.contactForName, + }); + + @override + State<_ExpandableParticipantStack> createState() => + _ExpandableParticipantStackState(); +} + +class _ExpandableParticipantStackState + extends State<_ExpandableParticipantStack> { + bool _expanded = false; + static const int _collapsedVisibleCount = 4; + + @override + Widget build(BuildContext context) { + final hasOverflow = widget.names.length > _collapsedVisibleCount; + final visibleNames = _expanded + ? widget.names + : widget.names.take(_collapsedVisibleCount).toList(); + final overflowCount = _expanded + ? 0 + : widget.names.length - visibleNames.length; + final spacing = _expanded ? 20.0 : 16.0; + const avatarSize = 24.0; + final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0); + final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing; + + return GestureDetector( + onTap: hasOverflow + ? () { + setState(() { + _expanded = !_expanded; + }); + } + : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + width: width, + height: avatarSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < visibleNames.length; i++) + Positioned( + left: i * spacing, + top: 0, + child: _ParticipantAvatar( + name: visibleNames[i], + contact: widget.contactForName(visibleNames[i]), + ), + ), + if (overflowCount > 0) + Positioned( + left: visibleNames.length * spacing, + top: 0, + child: _OverflowAvatar(count: overflowCount), + ), + ], + ), + ), + ); + } +} + +class _ChannelSheetAction { + final IconData icon; + final String label; + final Future Function() onTap; + final bool destructive; + + const _ChannelSheetAction({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + }); +} + +class _ChannelActionSheet extends StatelessWidget { + final Contact channel; + final List<_ChannelSheetAction> actions; + final VoidCallback onClose; + + const _ChannelActionSheet({ + required this.channel, + required this.actions, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + final title = channel.getLocalizedDisplayName(context); + final subtitle = channel.isPublicChannel + ? l10n.broadcastToAllNearby + : '${l10n.channel} ${channel.publicKey[1]}'; + final bottomInset = MediaQuery.of(context).viewPadding.bottom; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.82, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.12), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], + ), + child: Material( + color: colorScheme.surface, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ContactAvatar( + contact: channel, + radius: 28, + displayName: title, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.45, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + _ChannelSheetChip( + icon: channel.isPublicChannel + ? Icons.broadcast_on_personal_rounded + : Icons.tag_rounded, + label: channel.isPublicChannel + ? l10n.broadcastToAllNearby + : channel.publicKeyShort, + monospace: !channel.isPublicChannel, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + onPressed: onClose, + tooltip: l10n.close, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + if (actions.isNotEmpty) ...[ + const SizedBox(height: 20), + LayoutBuilder( + builder: (context, constraints) { + const spacing = 12.0; + const minTileWidth = 132.0; + final actionCount = actions.length; + final maxColumnsByWidth = + ((constraints.maxWidth + spacing) / + (minTileWidth + spacing)) + .floor() + .clamp(1, 3); + var columnCount = actionCount.clamp(1, maxColumnsByWidth); + if (actionCount > 3 && + columnCount > 2 && + actionCount % columnCount == 1) { + columnCount -= 1; + } + final itemWidth = + (constraints.maxWidth - (spacing * (columnCount - 1))) / + columnCount; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in actions) + SizedBox( + width: itemWidth, + child: _ChannelPrimaryActionButton(action: action), + ), + ], + ); + }, + ), + ], + ], + ), + ), + ), + ); + } +} + +class _ChannelPrimaryActionButton extends StatelessWidget { + final _ChannelSheetAction action; + + const _ChannelPrimaryActionButton({required this.action}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final accent = action.destructive ? colorScheme.error : colorScheme.primary; + final backgroundColor = action.destructive + ? colorScheme.errorContainer.withValues(alpha: 0.82) + : Color.alphaBlend( + accent.withValues(alpha: 0.12), + colorScheme.surfaceContainerLow, + ); + final foregroundColor = action.destructive + ? colorScheme.onErrorContainer + : accent; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: action.onTap, + child: Ink( + height: 100, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: action.destructive + ? colorScheme.error.withValues(alpha: 0.18) + : accent.withValues(alpha: 0.14), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: foregroundColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(11), + ), + child: Icon(action.icon, color: foregroundColor, size: 16), + ), + const SizedBox(height: 6), + Text( + action.label, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: action.destructive + ? colorScheme.onErrorContainer + : colorScheme.onSurface, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ChannelSheetChip extends StatelessWidget { + final IconData icon; + final String label; + final bool monospace; + + const _ChannelSheetChip({ + required this.icon, + required this.label, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + constraints: const BoxConstraints(maxWidth: 260), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + fontFamily: monospace ? 'monospace' : null, + ), + ), + ), + ], + ), + ); + } +} + +class _OverflowAvatar extends StatelessWidget { + final int count; + + const _OverflowAvatar({required this.count}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all(color: colorScheme.surface, width: 2), + ), + alignment: Alignment.center, + child: Text( + '+$count', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + fontSize: 9, + ), + ), + ); + } +} + +class _ParticipantAvatar extends StatelessWidget { + final String name; + final Contact? contact; + static const double _size = 24; + + const _ParticipantAvatar({required this.name, required this.contact}); + + @override + Widget build(BuildContext context) { + if (contact != null) { + final surfaceColor = Theme.of(context).colorScheme.surface; + return SizedBox( + width: _size, + height: _size, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: surfaceColor, width: 2), + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: ClipOval(child: ContactAvatar(contact: contact!, radius: 8)), + ), + ), + ); + } + + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: _size, + height: _size, + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + shape: BoxShape.circle, + border: Border.all(color: colorScheme.surface, width: 2), + ), + alignment: Alignment.center, + child: Text( + AvatarLabelHelper.buildLabel(name), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onTertiaryContainer, + fontSize: 9, + ), + ), + ); + } +} + +class _MetricChip extends StatelessWidget { + final IconData icon; + final String label; + final String helper; + + const _MetricChip({ + required this.icon, + required this.label, + required this.helper, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 5), + Text( + label, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(width: 4), + Text( + helper, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +class _ContactsRegionScopeSheet extends StatefulWidget { + final String? currentScopeName; + final AppLocalizations l10n; + final ValueChanged onScopeSelected; + + const _ContactsRegionScopeSheet({ + required this.currentScopeName, + required this.l10n, + required this.onScopeSelected, + }); + + @override + State<_ContactsRegionScopeSheet> createState() => + _ContactsRegionScopeSheetState(); +} + +class _ContactsRegionScopeSheetState extends State<_ContactsRegionScopeSheet> { + final TextEditingController _nameController = TextEditingController(); + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + void _submitManualName() { + var name = _nameController.text.trim(); + if (name.isEmpty) return; + if (!name.startsWith('#')) name = '#$name'; + widget.onScopeSelected(name); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = widget.l10n; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.72, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.regionScope, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + l10n.regionScopeWarning, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + + _ScopeOption( + label: l10n.regionScopeNone, + isSelected: widget.currentScopeName == null, + onTap: () => widget.onScopeSelected(null), + ), + const SizedBox(height: 8), + + Row( + children: [ + Expanded( + child: TextField( + controller: _nameController, + decoration: InputDecoration( + hintText: l10n.enterRegionName, + isDense: true, + prefixText: '#', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + onSubmitted: (_) => _submitManualName(), + ), + ), + const SizedBox(width: 8), + FilledButton.tonal( + onPressed: _submitManualName, + child: const Icon(Icons.check_rounded, size: 20), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _ScopeOption extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _ScopeOption({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + isSelected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_off_rounded, + size: 20, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart new file mode 100644 index 0000000..8c82aec --- /dev/null +++ b/lib/screens/device_config_screen.dart @@ -0,0 +1,3235 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:provider/provider.dart'; +import '../models/device_info.dart'; +import '../models/channel.dart'; +import '../models/contact.dart'; +import '../providers/channels_provider.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../services/validation_service.dart'; +import '../l10n/app_localizations.dart'; + +class DeviceConfigScreen extends StatefulWidget { + const DeviceConfigScreen({super.key}); + + @override + State createState() => _DeviceConfigScreenState(); +} + +class _DeviceConfigScreenState extends State { + static const int _bulkDeleteBatchSize = 8; + static const Duration _bulkDeleteInterItemDelay = Duration(milliseconds: 120); + static const Duration _bulkDeleteBatchDelay = Duration(milliseconds: 700); + static const Duration _bulkDeleteFinalSyncDelay = Duration(milliseconds: 900); + static const int _autoAddFilterModeFlag = 1; + static const int _fixedHardwareGpsIntervalSeconds = 5; + static const List<_RadioPreset> _radioPresets = [ + _RadioPreset( + id: 'australia', + label: 'Australia', + summary: '915.800 MHz, BW 250 kHz, SF10, CR5', + frequencyKhz: 915800, + bandwidth: 8, + spreadingFactor: 10, + codingRate: 5, + ), + _RadioPreset( + id: 'australia_narrow', + label: 'Australia (Narrow)', + summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8', + frequencyKhz: 916575, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 8, + ), + _RadioPreset( + id: 'australia_sa_wa', + label: 'Australia: SA, WA', + summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 923125, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'australia_qld', + label: 'Australia: QLD', + summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5', + frequencyKhz: 923125, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 5, + ), + _RadioPreset( + id: 'eu_uk_narrow', + label: 'EU/UK (Narrow)', + summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'eu_uk_deprecated', + label: 'EU/UK (Deprecated)', + summary: '869.525 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 869525, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'czech_republic_narrow', + label: 'Czech Republic (Narrow)', + summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 869432, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'eu_433_long_range', + label: 'EU 433MHz (Long Range)', + summary: '433.650 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 433650, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'new_zealand', + label: 'New Zealand', + summary: '917.375 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 917375, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'new_zealand_narrow', + label: 'New Zealand (Narrow)', + summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 917375, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'portugal_433', + label: 'Portugal 433', + summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6', + frequencyKhz: 433375, + bandwidth: 6, + spreadingFactor: 9, + codingRate: 6, + ), + _RadioPreset( + id: 'portugal_868', + label: 'Portugal 868', + summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 6, + ), + _RadioPreset( + id: 'switzerland', + label: 'Switzerland', + summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'usa_canada_recommended', + label: 'USA/Canada (Recommended)', + summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 910525, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'vietnam_narrow', + label: 'Vietnam (Narrow)', + summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5', + frequencyKhz: 920250, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 5, + ), + _RadioPreset( + id: 'vietnam_deprecated', + label: 'Vietnam (Deprecated)', + summary: '920.250 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 920250, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + ]; + + late TextEditingController _nameController; + late TextEditingController _latController; + late TextEditingController _lonController; + late TextEditingController _freqController; + late TextEditingController _txPowerController; + late TextEditingController _autoAddMaxHopsController; + late final ConnectionProvider _connectionProvider; + + int _baseTelemetryMode = 0; + int _locationTelemetryMode = 0; + int _environmentTelemetryMode = 0; + int _advertLocationPolicy = 0; + bool _multiAcksEnabled = false; + bool _repeatEnabled = false; + bool? _gpsEnabled; // null = not supported by hardware + bool _gpsLoading = false; + bool? _buzzerEnabled; + bool _buzzerLoading = false; + bool? _gpsHasFix; + int? _gpsSatelliteCount; + int? _gpsLatE6; + int? _gpsLonE6; + int? _gpsLastFixAgeSeconds; + DateTime? _gpsStatsLoadedAt; + Timer? _gpsStatsTicker; + Timer? _gpsRefreshTimer; + bool _isSyncingDeviceTime = false; + int? _selectedPathHashMode; + bool _autoAddDiscoveredContactsEnabled = true; + bool _autoAddUsersEnabled = true; + bool _autoAddRepeatersEnabled = true; + bool _autoAddRoomServersEnabled = true; + bool _autoAddSensorsEnabled = true; + bool _overwriteOldestAutoAddEnabled = false; + bool _showCustomRadioSettings = false; + bool _isSavingPublicInfo = false; + bool _isSavingRadioSettings = false; + bool _isSavingAutoDiscoverySettings = false; + bool _isClearingContacts = false; + bool _isClearingChannels = false; + bool _publicInfoSaved = false; + bool _radioSettingsSaved = false; + bool _autoDiscoverySettingsSaved = false; + bool _autoDiscoverySettingsDirty = false; + String? _lastAutoDiscoverySignature; + String? _lastRadioSettingsSignature; + String? _publicInfoError; + String? _radioSettingsError; + String? _autoDiscoverySettingsError; + String _selectedBandwidth = '62.5 kHz'; + int _selectedSpreadingFactor = 8; + int _selectedCodingRate = 8; + _RadioPreset? _selectedRadioPreset; + bool _radioSettingsDirty = false; + + final List _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', + ]; + + @override + void initState() { + super.initState(); + _connectionProvider = context.read(); + _connectionProvider.addListener(_handleConnectionProviderChanged); + _gpsStatsTicker = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted || _gpsLastFixAgeSeconds == null) return; + setState(() {}); + }); + _gpsRefreshTimer = Timer.periodic(const Duration(seconds: 5), (_) { + if (!mounted) return; + _loadGpsMode(); + }); + final deviceInfo = _connectionProvider.deviceInfo; + + _nameController = TextEditingController( + text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '', + ); + _latController = TextEditingController( + text: deviceInfo.advLat != null + ? (deviceInfo.advLat! / 1000000).toStringAsFixed(6) + : '0.0', + ); + _lonController = TextEditingController( + text: deviceInfo.advLon != null + ? (deviceInfo.advLon! / 1000000).toStringAsFixed(6) + : '0.0', + ); + _freqController = TextEditingController( + text: deviceInfo.radioFreq != null + ? (deviceInfo.radioFreq! / 1000).toStringAsFixed(3) + : '869.618', + ); + _txPowerController = TextEditingController( + text: deviceInfo.txPower?.toString() ?? '20', + ); + _autoAddMaxHopsController = TextEditingController( + text: (deviceInfo.autoAddMaxHops ?? 0).toString(), + ); + + _syncRadioSettingsState(deviceInfo); + + final telemetryModes = deviceInfo.telemetryModes; + _baseTelemetryMode = telemetryModes != null ? telemetryModes & 0x03 : 0; + _locationTelemetryMode = telemetryModes != null + ? (telemetryModes >> 2) & 0x03 + : 0; + _environmentTelemetryMode = telemetryModes != null + ? (telemetryModes >> 4) & 0x03 + : 0; + _advertLocationPolicy = deviceInfo.advertLocPolicy ?? 0; + _multiAcksEnabled = (deviceInfo.multiAcks ?? 0) != 0; + _selectedPathHashMode = deviceInfo.pathHashMode; + + // Initialize repeat mode from device info (firmware v9+) + _repeatEnabled = deviceInfo.clientRepeat ?? false; + _syncAutoDiscoveryState(deviceInfo); + _lastAutoDiscoverySignature = _autoDiscoverySignature(deviceInfo); + + // Fetch allowed repeat frequencies on open if device supports repeat mode + if (deviceInfo.clientRepeat != null && + deviceInfo.allowedRepeatFreqRanges == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().getAllowedRepeatFreq(); + }); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + _connectionProvider.getBatteryAndStorage(); + unawaited(_connectionProvider.getAutoaddConfig()); + unawaited(_loadGpsMode()); + }); + } + + @override + void dispose() { + _connectionProvider.removeListener(_handleConnectionProviderChanged); + _nameController.dispose(); + _latController.dispose(); + _lonController.dispose(); + _freqController.dispose(); + _txPowerController.dispose(); + _autoAddMaxHopsController.dispose(); + _gpsStatsTicker?.cancel(); + _gpsRefreshTimer?.cancel(); + super.dispose(); + } + + String _bandwidthFromValue(int bw) { + switch (bw) { + case 0: + return '7.8 kHz'; + case 1: + return '10.4 kHz'; + case 2: + return '15.6 kHz'; + case 3: + return '20.8 kHz'; + case 4: + return '31.25 kHz'; + case 5: + return '41.7 kHz'; + case 6: + return '62.5 kHz'; + case 7: + return '125 kHz'; + case 8: + return '250 kHz'; + case 9: + return '500 kHz'; + default: + return '62.5 kHz'; + } + } + + int _bandwidthToValue(String bw) { + return _bandwidthOptions.indexOf(bw); + } + + int? _normalizeBandwidthValue(int? bw) { + if (bw == null) { + return null; + } + if (bw >= 0 && bw <= 9) { + return bw; + } + switch (bw) { + case 7800: + return 0; + case 10400: + return 1; + case 15600: + return 2; + case 20800: + return 3; + case 31250: + return 4; + case 41700: + return 5; + case 62500: + return 6; + case 125000: + return 7; + case 250000: + return 8; + case 500000: + return 9; + default: + return null; + } + } + + int? _normalizeSpreadingFactor(int? spreadingFactor) { + if (spreadingFactor == null || + spreadingFactor < 7 || + spreadingFactor > 12) { + return null; + } + return spreadingFactor; + } + + int? _normalizeCodingRate(int? codingRate) { + if (codingRate == null) { + return null; + } + if (codingRate >= 5 && codingRate <= 8) { + return codingRate; + } + if (codingRate >= 1 && codingRate <= 4) { + return codingRate + 4; + } + return null; + } + + _RadioPreset? _matchRadioPreset({ + int? frequencyKhz, + int? bandwidth, + int? spreadingFactor, + int? codingRate, + }) { + final normalizedBandwidth = _normalizeBandwidthValue(bandwidth); + final normalizedSpreadingFactor = _normalizeSpreadingFactor( + spreadingFactor, + ); + final normalizedCodingRate = _normalizeCodingRate(codingRate); + + if (frequencyKhz == null || + normalizedBandwidth == null || + normalizedSpreadingFactor == null || + normalizedCodingRate == null) { + return null; + } + + for (final preset in _radioPresets) { + if (preset.frequencyKhz == frequencyKhz && + preset.bandwidth == normalizedBandwidth && + preset.spreadingFactor == normalizedSpreadingFactor && + preset.codingRate == normalizedCodingRate) { + return preset; + } + } + return null; + } + + String _radioSettingsSignature(DeviceInfo deviceInfo) { + return [ + deviceInfo.radioFreq, + _normalizeBandwidthValue(deviceInfo.radioBw), + _normalizeSpreadingFactor(deviceInfo.radioSf), + _normalizeCodingRate(deviceInfo.radioCr), + deviceInfo.txPower, + deviceInfo.clientRepeat, + deviceInfo.pathHashMode, + ].join('|'); + } + + void _syncRadioSettingsState(DeviceInfo deviceInfo) { + final normalizedBandwidth = _normalizeBandwidthValue(deviceInfo.radioBw); + final normalizedSpreadingFactor = _normalizeSpreadingFactor( + deviceInfo.radioSf, + ); + final normalizedCodingRate = _normalizeCodingRate(deviceInfo.radioCr); + + if (deviceInfo.radioFreq != null) { + _freqController.text = (deviceInfo.radioFreq! / 1000).toStringAsFixed(3); + } + if (normalizedBandwidth != null) { + _selectedBandwidth = _bandwidthFromValue(normalizedBandwidth); + } + if (normalizedSpreadingFactor != null) { + _selectedSpreadingFactor = normalizedSpreadingFactor; + } + if (normalizedCodingRate != null) { + _selectedCodingRate = normalizedCodingRate; + } + if (deviceInfo.txPower != null) { + _txPowerController.text = deviceInfo.txPower!.toString(); + } + if (deviceInfo.clientRepeat != null) { + _repeatEnabled = deviceInfo.clientRepeat!; + } + _selectedPathHashMode = deviceInfo.pathHashMode; + _selectedRadioPreset = _matchRadioPreset( + frequencyKhz: deviceInfo.radioFreq, + bandwidth: deviceInfo.radioBw, + spreadingFactor: deviceInfo.radioSf, + codingRate: deviceInfo.radioCr, + ); + _showCustomRadioSettings = _selectedRadioPreset == null; + _radioSettingsDirty = false; + _lastRadioSettingsSignature = _radioSettingsSignature(deviceInfo); + } + + void _applyRadioPreset(_RadioPreset preset) { + setState(() { + _selectedRadioPreset = preset; + _freqController.text = (preset.frequencyKhz / 1000).toStringAsFixed(3); + _selectedBandwidth = _bandwidthFromValue(preset.bandwidth); + _selectedSpreadingFactor = preset.spreadingFactor; + _selectedCodingRate = preset.codingRate; + _showCustomRadioSettings = false; + }); + } + + void _syncRadioPresetSelection() { + final freqResult = ValidationService().parseFrequency(_freqController.text); + final matchedPreset = freqResult.isSuccess + ? _matchRadioPreset( + frequencyKhz: (freqResult.value! * 1000).round(), + bandwidth: _bandwidthToValue(_selectedBandwidth), + spreadingFactor: _selectedSpreadingFactor, + codingRate: _selectedCodingRate, + ) + : null; + + _selectedRadioPreset = matchedPreset; + _showCustomRadioSettings = matchedPreset == null; + } + + void _markPublicInfoDirty() { + if (_publicInfoSaved || _publicInfoError != null) { + setState(() { + _publicInfoSaved = false; + _publicInfoError = null; + }); + } + } + + void _markRadioSettingsDirty() { + _radioSettingsDirty = true; + if (_radioSettingsSaved || _radioSettingsError != null) { + setState(() { + _radioSettingsSaved = false; + _radioSettingsError = null; + }); + } + } + + void _markAutoDiscoverySettingsDirty() { + _autoDiscoverySettingsDirty = true; + if (_autoDiscoverySettingsSaved || _autoDiscoverySettingsError != null) { + setState(() { + _autoDiscoverySettingsSaved = false; + _autoDiscoverySettingsError = null; + }); + } + } + + bool get _hasUnsavedChanges => + _radioSettingsDirty || _autoDiscoverySettingsDirty; + + Future _handlePopInvoked(bool didPop, Object? result) async { + if (didPop) return; + + final discard = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Discard unsaved changes?'), + content: const Text( + 'You have changes that have not been saved to the device.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Keep editing'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Discard'), + ), + ], + ), + ); + + if (discard == true && mounted) { + Navigator.of(context).pop(); + } + } + + String _autoDiscoverySignature(DeviceInfo deviceInfo) { + return [ + deviceInfo.manualAddContacts, + deviceInfo.autoAddUsers, + deviceInfo.autoAddRepeaters, + deviceInfo.autoAddRoomServers, + deviceInfo.autoAddSensors, + deviceInfo.autoAddOverwriteOldest, + deviceInfo.autoAddMaxHops, + ].join('|'); + } + + void _handleConnectionProviderChanged() { + if (!mounted) return; + + final deviceInfo = _connectionProvider.deviceInfo; + final nextRadioSettingsSignature = _radioSettingsSignature(deviceInfo); + final nextAutoDiscoverySignature = _autoDiscoverySignature(deviceInfo); + final shouldSyncRadioSettings = + !_radioSettingsDirty && + nextRadioSettingsSignature != _lastRadioSettingsSignature; + final shouldSyncAutoDiscovery = + !_autoDiscoverySettingsDirty && + nextAutoDiscoverySignature != _lastAutoDiscoverySignature; + + if (!shouldSyncRadioSettings && !shouldSyncAutoDiscovery) { + return; + } + + setState(() { + if (shouldSyncRadioSettings) { + _syncRadioSettingsState(deviceInfo); + } + if (shouldSyncAutoDiscovery) { + _lastAutoDiscoverySignature = nextAutoDiscoverySignature; + _syncAutoDiscoveryState(deviceInfo); + } + }); + } + + bool _hasAutoAddTargetsEnabled(DeviceInfo deviceInfo) { + return (deviceInfo.autoAddUsers ?? false) || + (deviceInfo.autoAddRepeaters ?? false) || + (deviceInfo.autoAddRoomServers ?? false) || + (deviceInfo.autoAddSensors ?? false); + } + + void _syncAutoDiscoveryState(DeviceInfo deviceInfo) { + final hasFetchedAutoAddConfig = + deviceInfo.autoAddUsers != null || + deviceInfo.autoAddRepeaters != null || + deviceInfo.autoAddRoomServers != null || + deviceInfo.autoAddSensors != null || + deviceInfo.autoAddOverwriteOldest != null; + + _autoAddDiscoveredContactsEnabled = hasFetchedAutoAddConfig + ? _hasAutoAddTargetsEnabled(deviceInfo) + : !(deviceInfo.manualAddContacts ?? false); + _autoAddUsersEnabled = deviceInfo.autoAddUsers ?? true; + _autoAddRepeatersEnabled = deviceInfo.autoAddRepeaters ?? true; + _autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true; + _autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true; + _overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false; + _autoAddMaxHopsController.text = (deviceInfo.autoAddMaxHops ?? 0) + .toString(); + } + + int? _currentGpsLastFixAgeSeconds() { + final baseAgeSeconds = _gpsLastFixAgeSeconds; + final loadedAt = _gpsStatsLoadedAt; + if (baseAgeSeconds == null || loadedAt == null) { + return null; + } + final elapsedSeconds = DateTime.now().difference(loadedAt).inSeconds; + if (elapsedSeconds <= 0) { + return baseAgeSeconds; + } + return baseAgeSeconds + elapsedSeconds; + } + + String _formatGpsFixValue() { + if (_gpsEnabled == false) { + return 'Off'; + } + if (_gpsHasFix == true) { + return 'Locked'; + } + if (_gpsEnabled == true) { + return 'Searching'; + } + return 'Unavailable'; + } + + String _formatGpsLocationValue() { + final latE6 = _gpsLatE6; + final lonE6 = _gpsLonE6; + if (latE6 == null || lonE6 == null) { + return 'No fix yet'; + } + return '${(latE6 / 1000000).toStringAsFixed(6)}, ${(lonE6 / 1000000).toStringAsFixed(6)}'; + } + + String _formatGpsLastFixValue(AppLocalizations l10n) { + final ageSeconds = _currentGpsLastFixAgeSeconds(); + if (ageSeconds == null) { + return 'Never'; + } + if (ageSeconds <= 0) { + return l10n.justNow; + } + if (ageSeconds < 60) { + return l10n.secondsAgo(ageSeconds); + } + + final ageMinutes = ageSeconds ~/ 60; + if (ageMinutes < 60) { + return l10n.minutesAgo(ageMinutes); + } + + final ageHours = ageMinutes ~/ 60; + if (ageHours < 24) { + return l10n.hoursAgo(ageHours); + } + + return l10n.daysAgo(ageHours ~/ 24); + } + + int _telemetryModesForSave() { + return (_environmentTelemetryMode << 4) | + (_locationTelemetryMode << 2) | + _baseTelemetryMode; + } + + int _advertLocationPolicyForSave() { + return _advertLocationPolicy; + } + + int _multiAcksForSave() { + return _multiAcksEnabled ? 1 : 0; + } + + Future _savePublicInfo() async { + final connectionProvider = context.read(); + final validator = ValidationService(); + + setState(() { + _isSavingPublicInfo = true; + _publicInfoSaved = false; + _publicInfoError = null; + }); + + try { + // Save name + if (_nameController.text.isNotEmpty) { + await connectionProvider.setAdvertName(_nameController.text); + } + + // Save stored coordinates only when the firmware advert policy uses prefs. + if (_advertLocationPolicy == 2) { + // Parse and validate coordinates + final latResult = validator.parseLatitude(_latController.text); + if (!latResult.isSuccess) { + if (mounted) { + setState(() { + _publicInfoError = latResult.errorMessage!; + _isSavingPublicInfo = false; + }); + } + return; + } + + final lonResult = validator.parseLongitude(_lonController.text); + if (!lonResult.isSuccess) { + if (mounted) { + setState(() { + _publicInfoError = lonResult.errorMessage!; + _isSavingPublicInfo = false; + }); + } + return; + } + + await connectionProvider.setAdvertLatLon( + latitude: latResult.value!, + longitude: lonResult.value!, + ); + } + + await connectionProvider.setOtherParams( + manualAddContacts: _autoAddFilterModeFlag, + telemetryModes: _telemetryModesForSave(), + advertLocationPolicy: _advertLocationPolicyForSave(), + multiAcks: _multiAcksForSave(), + ); + + // Refetch device info to update UI with new settings + await connectionProvider.refreshDeviceInfo(); + + if (mounted) { + setState(() { + _isSavingPublicInfo = false; + _publicInfoSaved = true; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isSavingPublicInfo = false; + _publicInfoError = AppLocalizations.of( + context, + )!.failedToSave(e.toString()); + }); + } + } + } + + Future _confirmApplyRadioSettings({ + required int freqKhz, + required int bandwidth, + required int spreadingFactor, + required int codingRate, + required int txPower, + }) async { + final deviceInfo = context.read().deviceInfo; + final oldFreqKhz = deviceInfo.radioFreq; + final oldBandwidth = _normalizeBandwidthValue(deviceInfo.radioBw); + final oldSpreadingFactor = _normalizeSpreadingFactor(deviceInfo.radioSf); + final oldCodingRate = _normalizeCodingRate(deviceInfo.radioCr); + final oldTxPower = deviceInfo.txPower; + + String formatFreq(int? khz) => + khz != null ? (khz / 1000).toStringAsFixed(3) : '—'; + + final changes = [ + if (oldFreqKhz != freqKhz) + '${AppLocalizations.of(context)!.frequencyMHz}: ${formatFreq(oldFreqKhz)} → ${formatFreq(freqKhz)}', + if (oldBandwidth != bandwidth) + '${AppLocalizations.of(context)!.bandwidth}: ${oldBandwidth != null ? _bandwidthFromValue(oldBandwidth) : '—'} → ${_bandwidthFromValue(bandwidth)}', + if (oldSpreadingFactor != spreadingFactor) + '${AppLocalizations.of(context)!.spreadingFactor}: ${oldSpreadingFactor ?? '—'} → $spreadingFactor', + if (oldCodingRate != codingRate) + '${AppLocalizations.of(context)!.codingRate}: ${oldCodingRate ?? '—'} → $codingRate', + if (oldTxPower != txPower) + '${AppLocalizations.of(context)!.txPowerDbm}: ${oldTxPower ?? '—'} → $txPower', + ]; + + if (changes.isEmpty) { + return true; + } + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Apply radio settings?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final change in changes) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(change), + ), + const SizedBox(height: 8), + Text( + 'Changing these settings will disconnect you from any mesh nodes not using the same settings.', + style: TextStyle( + color: Theme.of(dialogContext).colorScheme.error, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Apply'), + ), + ], + ), + ); + + return confirmed == true; + } + + Future _saveRadioSettings() async { + final connectionProvider = context.read(); + final validator = ValidationService(); + final deviceInfo = connectionProvider.deviceInfo; + + setState(() { + _isSavingRadioSettings = true; + _radioSettingsSaved = false; + _radioSettingsError = null; + }); + + try { + // Parse and validate frequency + final freqResult = validator.parseFrequency(_freqController.text); + if (!freqResult.isSuccess) { + if (mounted) { + setState(() { + _radioSettingsError = freqResult.errorMessage!; + _isSavingRadioSettings = false; + }); + } + return; + } + + // Parse and validate TX power + final txPowerResult = validator.parseTxPower( + _txPowerController.text, + maxPower: deviceInfo.maxTxPower, + ); + if (!txPowerResult.isSuccess) { + if (mounted) { + setState(() { + _radioSettingsError = txPowerResult.errorMessage!; + _isSavingRadioSettings = false; + }); + } + return; + } + + // Convert from MHz to kHz for protocol + final freqKhz = (freqResult.value! * 1000).round(); + + // Confirm before applying — wrong radio params knock this node off + // the mesh shared with the rest of the team. + final confirmed = await _confirmApplyRadioSettings( + freqKhz: freqKhz, + bandwidth: _bandwidthToValue(_selectedBandwidth), + spreadingFactor: _selectedSpreadingFactor, + codingRate: _selectedCodingRate, + txPower: txPowerResult.value!, + ); + if (!confirmed || !mounted) { + if (mounted) { + setState(() { + _isSavingRadioSettings = false; + }); + } + return; + } + + await connectionProvider.setRadioParams( + frequency: freqKhz, + bandwidth: _bandwidthToValue(_selectedBandwidth), + spreadingFactor: _selectedSpreadingFactor, + codingRate: _selectedCodingRate, + repeat: deviceInfo.clientRepeat != null ? _repeatEnabled : null, + ); + + // Save TX power + await connectionProvider.setTxPower(txPowerResult.value!); + + if (_selectedPathHashMode != null) { + await connectionProvider.setPathHashMode(_selectedPathHashMode!); + } + + // Refetch device info to update UI with new settings + await connectionProvider.refreshDeviceInfo(); + + if (mounted) { + setState(() { + _syncRadioSettingsState(connectionProvider.deviceInfo); + _isSavingRadioSettings = false; + _radioSettingsSaved = true; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isSavingRadioSettings = false; + _radioSettingsError = AppLocalizations.of( + context, + )!.failedToSave(e.toString()); + }); + } + } + } + + Future _saveAutoDiscoverySettings() async { + final connectionProvider = context.read(); + final autoAddUsers = + _autoAddDiscoveredContactsEnabled && _autoAddUsersEnabled; + final autoAddRepeaters = + _autoAddDiscoveredContactsEnabled && _autoAddRepeatersEnabled; + final autoAddRoomServers = + _autoAddDiscoveredContactsEnabled && _autoAddRoomServersEnabled; + final autoAddSensors = + _autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled; + final overwriteOldest = + _autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled; + final maxHopsText = _autoAddMaxHopsController.text.trim(); + final maxHops = int.tryParse(maxHopsText); + + setState(() { + _isSavingAutoDiscoverySettings = true; + _autoDiscoverySettingsSaved = false; + _autoDiscoverySettingsError = null; + }); + + try { + if (maxHops == null || maxHops < 0 || maxHops > 64) { + throw Exception('Auto-add max hops must be between 0 and 64.'); + } + await connectionProvider.setAutoaddConfig( + autoAddUsers: autoAddUsers, + autoAddRepeaters: autoAddRepeaters, + autoAddRoomServers: autoAddRoomServers, + autoAddSensors: autoAddSensors, + overwriteOldest: overwriteOldest, + maxHops: maxHops, + ); + await connectionProvider.setOtherParams( + manualAddContacts: _autoAddFilterModeFlag, + telemetryModes: _telemetryModesForSave(), + advertLocationPolicy: _advertLocationPolicyForSave(), + multiAcks: _multiAcksForSave(), + ); + await connectionProvider.getAutoaddConfig(); + + if (mounted) { + setState(() { + _syncAutoDiscoveryState(connectionProvider.deviceInfo); + _lastAutoDiscoverySignature = _autoDiscoverySignature( + connectionProvider.deviceInfo, + ); + _isSavingAutoDiscoverySettings = false; + _autoDiscoverySettingsSaved = true; + _autoDiscoverySettingsDirty = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isSavingAutoDiscoverySettings = false; + _autoDiscoverySettingsError = AppLocalizations.of( + context, + )!.failedToSave(e.toString()); + }); + } + } + } + + Future _loadGpsMode() async { + try { + final vars = await _connectionProvider.getCustomVars(); + if (!mounted) return; + final gpsValue = vars['gps']; + final gpsFixValue = vars['gps_fix']; + final gpsSatsValue = vars['gps_sats']; + final gpsLatValue = vars['gps_lat_e6']; + final gpsLonValue = vars['gps_lon_e6']; + final gpsLastFixAgeValue = vars['gps_last_fix_age_s']; + final buzzerValue = vars['buzzer']; + setState(() { + _gpsEnabled = gpsValue != null ? gpsValue == '1' : null; + _buzzerEnabled = buzzerValue != null ? buzzerValue == '1' : null; + _gpsHasFix = gpsFixValue != null ? gpsFixValue == '1' : null; + _gpsSatelliteCount = int.tryParse(gpsSatsValue ?? ''); + _gpsLatE6 = int.tryParse(gpsLatValue ?? ''); + _gpsLonE6 = int.tryParse(gpsLonValue ?? ''); + _gpsLastFixAgeSeconds = int.tryParse(gpsLastFixAgeValue ?? ''); + _gpsStatsLoadedAt = DateTime.now(); + }); + } catch (_) { + // Device may not support custom vars (old firmware / no GPS hardware) + } + } + + Future _setBuzzerMode(bool enabled) async { + setState(() => _buzzerLoading = true); + try { + await _connectionProvider.setCustomVar('buzzer', enabled ? '1' : '0'); + if (!mounted) return; + setState(() { + _buzzerEnabled = enabled; + _buzzerLoading = false; + }); + unawaited(_loadGpsMode()); + } catch (e) { + if (!mounted) return; + setState(() => _buzzerLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.failedToSetBuzzerMode(e.toString()))), + ); + } + } + + Future _setGpsMode(bool enabled) async { + setState(() => _gpsLoading = true); + try { + await _connectionProvider.setCustomVar('gps', enabled ? '1' : '0'); + if (!mounted) return; + setState(() { + _gpsEnabled = enabled; + _gpsLoading = false; + }); + unawaited(_loadGpsMode()); + } catch (e) { + if (!mounted) return; + setState(() => _gpsLoading = false); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.failedToSetGpsMode(e.toString())))); + } + } + + Future _useCurrentLocation() async { + try { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.locationServicesDisabled, + ), + ), + ); + } + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.locationPermissionDenied, + ), + ), + ); + } + return; + } + } + + if (permission == LocationPermission.deniedForever) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.locationPermissionPermanentlyDenied, + ), + ), + ); + } + return; + } + + Position position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + ), + ); + + if (!mounted) return; + + setState(() { + _latController.text = position.latitude.toStringAsFixed(6); + _lonController.text = position.longitude.toStringAsFixed(6); + _advertLocationPolicy = 2; + if (_locationTelemetryMode == 0) { + _locationTelemetryMode = 2; + } + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.locationBroadcast( + position.latitude.toStringAsFixed(6), + position.longitude.toStringAsFixed(6), + ), + ), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.failedToGetLocation(e.toString()), + ), + ), + ); + } + } + } + + Future _syncDeviceTime() async { + setState(() => _isSyncingDeviceTime = true); + try { + _connectionProvider.clearError(); + await _connectionProvider.syncDeviceTime(); + final syncError = _connectionProvider.error; + if (syncError != null) { + throw Exception(syncError); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.deviceTimeSynced), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.failedToSyncDeviceTime(e.toString())), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } finally { + if (mounted) { + setState(() => _isSyncingDeviceTime = false); + } + } + } + + Future _confirmFactoryReset() async { + final deviceInfo = context.read().deviceInfo; + final deviceName = (deviceInfo.selfName ?? deviceInfo.deviceName)?.trim(); + final confirmationWord = (deviceName == null || deviceName.isEmpty) + ? 'WIPE' + : deviceName; + final confirmationController = TextEditingController(); + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) => AlertDialog( + title: Text(AppLocalizations.of(context)!.wipeDeviceData), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'This will erase all data on the connected device, including contacts, keys, and saved settings. This cannot be undone.', + ), + const SizedBox(height: 12), + Text('Type "$confirmationWord" to confirm:'), + const SizedBox(height: 8), + TextField( + controller: confirmationController, + autofocus: true, + decoration: InputDecoration(hintText: confirmationWord), + onChanged: (_) => setDialogState(() {}), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + onPressed: + confirmationController.text.trim() == confirmationWord + ? () => Navigator.of(dialogContext).pop(true) + : null, + child: Text(AppLocalizations.of(context)!.wipeDevice), + ), + ], + ), + ), + ); + confirmationController.dispose(); + + if (confirmed != true || !mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final connectionProvider = context.read(); + + try { + await connectionProvider.factoryResetDevice(); + if (!mounted) return; + + if (connectionProvider.error != null) { + messenger.showSnackBar( + SnackBar( + content: Text(connectionProvider.error!), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } + + messenger.showSnackBar( + const SnackBar( + content: Text( + 'Factory reset command sent. The device should reboot and disconnect shortly.', + ), + ), + ); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.failedToWipeDeviceData(e.toString())), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + + Future _confirmClearAllContacts() async { + final contacts = context + .read() + .contacts + .where((contact) => !contact.isChannel) + .toList(); + if (contacts.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.noDeviceContactsToClear), + ), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(context)!.clearAllContacts), + content: Text( + 'This will remove ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the connected device. Channels and radio settings will not be changed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(AppLocalizations.of(context)!.clearContacts), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final connectionProvider = context.read(); + final contactsProvider = context.read(); + connectionProvider.clearError(); + + setState(() { + _isClearingContacts = true; + }); + + try { + var processed = 0; + for (final contact in List.from(contacts)) { + await contactsProvider.removeContact( + contact.publicKeyHex, + onRemoveFromDevice: connectionProvider.removeContact, + ); + processed++; + await Future.delayed(_bulkDeleteInterItemDelay); + if (processed % _bulkDeleteBatchSize == 0) { + await Future.delayed(_bulkDeleteBatchDelay); + } + } + + // Flush the updated local contact set to storage immediately. + await contactsProvider.persistNow(); + await Future.delayed(_bulkDeleteFinalSyncDelay); + await connectionProvider.getContacts(); + if (connectionProvider.error != null) { + throw Exception(connectionProvider.error!); + } + + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text( + 'Cleared ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the device.', + ), + ), + ); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.failedToClearContacts(e.toString())), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } finally { + if (mounted) { + setState(() { + _isClearingContacts = false; + }); + } + } + } + + Future _confirmClearAllChannels() async { + final channels = context + .read() + .channels + .where((channel) => !channel.isPublicChannel && channel.name.isNotEmpty) + .toList(); + if (channels.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.noCustomChannelsToClear), + ), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(context)!.clearAllChannels), + content: Text( + 'This will remove ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the connected device. Contacts and radio settings will not be changed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(AppLocalizations.of(context)!.clearChannels), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final connectionProvider = context.read(); + final channelsProvider = context.read(); + final contactsProvider = context.read(); + connectionProvider.clearError(); + + setState(() { + _isClearingChannels = true; + }); + + try { + var processed = 0; + for (final channel in List.from(channels)) { + await connectionProvider.deleteChannel(channel.index); + processed++; + await Future.delayed(_bulkDeleteInterItemDelay); + if (processed % _bulkDeleteBatchSize == 0) { + await Future.delayed(_bulkDeleteBatchDelay); + } + } + + // Force local channel/contact cache cleanup before the device resync. + for (final channel in channels) { + channelsProvider.removeChannel(channel.index); + final publicKeyBytes = Uint8List(32); + publicKeyBytes[0] = 0xFF; + publicKeyBytes[1] = channel.index; + final publicKeyHex = publicKeyBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + await contactsProvider.removeContact(publicKeyHex); + } + + await contactsProvider.persistNow(); + await Future.delayed(_bulkDeleteFinalSyncDelay); + await connectionProvider.syncChannels(); + if (connectionProvider.error != null) { + throw Exception(connectionProvider.error!); + } + + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text( + 'Cleared ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the device.', + ), + ), + ); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.failedToClearChannels(e.toString())), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } finally { + if (mounted) { + setState(() { + _isClearingChannels = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final deviceInfo = context.watch().deviceInfo; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final locationSet = _advertLocationPolicy != 0; + + final scaffold = Scaffold( + appBar: AppBar(title: Text(AppLocalizations.of(context)!.deviceSettings)), + body: ColoredBox( + color: colorScheme.surface, + child: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + _ConfigHeroCard( + title: + deviceInfo.selfName ?? deviceInfo.deviceName ?? 'MeshCore', + subtitle: + '${_getDeviceTypeString(context, deviceInfo.deviceType)} • ${deviceInfo.semanticVersion ?? deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown}', + stats: [ + _HeroStatData( + label: AppLocalizations.of(context)!.location, + value: locationSet ? 'Shared' : 'Hidden', + icon: Icons.my_location_rounded, + emphasized: locationSet, + ), + _HeroStatData( + label: AppLocalizations.of(context)!.model, + value: + deviceInfo.manufacturerModel ?? + AppLocalizations.of(context)!.unknown, + icon: Icons.memory_rounded, + ), + ], + ), + SizedBox(height: 12), + _ConfigSectionCard( + title: AppLocalizations.of(context)!.deviceInfo, + subtitle: + 'Capabilities, storage, and maintenance tools.', + icon: Icons.info_outline_rounded, + child: LayoutBuilder( + builder: (context, constraints) { + final cardWidth = constraints.maxWidth > 420 + ? (constraints.maxWidth - 24) / 3 + : (constraints.maxWidth - 12) / 2; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + SizedBox( + width: cardWidth, + child: _StorageStat( + label: AppLocalizations.of(context)!.blePin, + value: _formatBlePin(deviceInfo.blePin), + compact: true, + ), + ), + SizedBox( + width: cardWidth, + child: _StorageStat( + label: AppLocalizations.of( + context, + )!.maxContacts, + value: + deviceInfo.maxContacts?.toString() ?? + AppLocalizations.of(context)!.unknown, + compact: true, + ), + ), + SizedBox( + width: cardWidth, + child: _StorageStat( + label: AppLocalizations.of( + context, + )!.maxChannels, + value: + deviceInfo.maxChannels?.toString() ?? + AppLocalizations.of(context)!.unknown, + compact: true, + ), + ), + if (deviceInfo.pathHashMode != null) + SizedBox( + width: cardWidth, + child: _StorageStat( + label: AppLocalizations.of(context)!.pathHash, + value: _pathHashModeLabel( + deviceInfo.pathHashMode!, + ), + compact: true, + ), + ), + SizedBox( + width: cardWidth, + child: _StorageStat( + label: AppLocalizations.of(context)!.used, + value: _formatStorage( + deviceInfo.storageUsedKb ?? 0, + ), + compact: true, + ), + ), + SizedBox( + width: cardWidth, + child: _StorageStat( + label: AppLocalizations.of(context)!.total, + value: deviceInfo.storageTotalKb != null + ? _formatStorage(deviceInfo.storageTotalKb!) + : AppLocalizations.of(context)!.unknown, + compact: true, + ), + ), + ], + ), + const SizedBox(height: 10), + _StorageUsageMeter(deviceInfo: deviceInfo), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isSyncingDeviceTime + ? null + : _syncDeviceTime, + icon: _isSyncingDeviceTime + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.schedule_rounded, size: 20), + label: Text( + _isSyncingDeviceTime + ? 'Syncing...' + : 'Sync device time', + ), + ), + ), + if (_buzzerEnabled != null) ...[ + const SizedBox(height: 10), + _SettingHighlightCard( + icon: _buzzerEnabled! + ? Icons.volume_up_rounded + : Icons.volume_off_rounded, + title: AppLocalizations.of(context)!.buzzerAlerts, + description: 'Onboard buzzer for radio alerts', + accentColor: _buzzerEnabled! + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: _buzzerLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Switch( + value: _buzzerEnabled!, + onChanged: _setBuzzerMode, + ), + ), + ], + const SizedBox(height: 10), + _SettingHighlightCard( + icon: _multiAcksEnabled + ? Icons.mark_email_read_outlined + : Icons.mark_email_unread_outlined, + title: AppLocalizations.of(context)!.multiAckMode, + description: 'Request extra acknowledgements', + accentColor: _multiAcksEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _multiAcksEnabled, + onChanged: (value) { + setState(() { + _multiAcksEnabled = value; + _markPublicInfoDirty(); + }); + }, + ), + ), + ], + ); + }, + ), + ), + SizedBox(height: 12), + _ConfigSectionCard( + title: AppLocalizations.of(context)!.publicInfo, + subtitle: AppLocalizations.of(context)!.nameAndTelemetryShared, + icon: Icons.public_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _nameController, + onChanged: (_) => _markPublicInfoDirty(), + decoration: InputDecoration( + labelText: 'Device name', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: 'Visible to other devices on the mesh', + ), + ), + const SizedBox(height: 10), + _ConfigDropdownField( + label: AppLocalizations.of(context)!.baseTelemetry, + value: _baseTelemetryMode, + items: [ + DropdownMenuItem(value: 0, child: Text(AppLocalizations.of(context)!.deny)), + DropdownMenuItem( + value: 1, + child: Text(AppLocalizations.of(context)!.useContactFlags), + ), + DropdownMenuItem(value: 2, child: Text(AppLocalizations.of(context)!.allowAll)), + ], + onChanged: (value) { + if (value == null) return; + setState(() { + _baseTelemetryMode = value; + _markPublicInfoDirty(); + }); + }, + ), + const SizedBox(height: 10), + _ConfigDropdownField( + label: AppLocalizations.of(context)!.locationTelemetry, + value: _locationTelemetryMode, + items: [ + DropdownMenuItem(value: 0, child: Text(AppLocalizations.of(context)!.deny)), + DropdownMenuItem( + value: 1, + child: Text(AppLocalizations.of(context)!.useContactFlags), + ), + DropdownMenuItem(value: 2, child: Text(AppLocalizations.of(context)!.allowAll)), + ], + onChanged: (value) { + if (value == null) return; + setState(() { + _locationTelemetryMode = value; + _markPublicInfoDirty(); + }); + }, + ), + const SizedBox(height: 10), + _ConfigDropdownField( + label: AppLocalizations.of(context)!.environmentalTelemetry, + value: _environmentTelemetryMode, + items: [ + DropdownMenuItem(value: 0, child: Text(AppLocalizations.of(context)!.deny)), + DropdownMenuItem( + value: 1, + child: Text(AppLocalizations.of(context)!.useContactFlags), + ), + DropdownMenuItem(value: 2, child: Text(AppLocalizations.of(context)!.allowAll)), + ], + onChanged: (value) { + if (value == null) return; + setState(() { + _environmentTelemetryMode = value; + _markPublicInfoDirty(); + }); + }, + ), + const SizedBox(height: 12), + if (_publicInfoError != null) ...[ + Text( + _publicInfoError!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + const SizedBox(height: 10), + ], + SizedBox( + width: double.infinity, + child: _SaveActionButton( + onPressed: _isSavingPublicInfo ? null : _savePublicInfo, + isSaving: _isSavingPublicInfo, + isSaved: _publicInfoSaved, + label: AppLocalizations.of(context)!.savePublicInfo, + ), + ), + ], + ), + ), + SizedBox(height: 12), + _ConfigSectionCard( + title: AppLocalizations.of(context)!.gpsSection, + subtitle: AppLocalizations.of(context)!.locationSharingHardwareAndUpdateInterval, + icon: Icons.gps_fixed, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ConfigDropdownField( + label: AppLocalizations.of(context)!.gpsAdvertPolicy, + value: _advertLocationPolicy, + items: [ + DropdownMenuItem(value: 0, child: Text(AppLocalizations.of(context)!.hidden)), + DropdownMenuItem( + value: 1, + child: Text(AppLocalizations.of(context)!.shareLiveGps), + ), + DropdownMenuItem( + value: 2, + child: Text(AppLocalizations.of(context)!.useSavedCoordinates), + ), + ], + onChanged: (value) { + if (value == null) return; + setState(() { + _advertLocationPolicy = value; + _markPublicInfoDirty(); + }); + }, + ), + if (_gpsEnabled != null) ...[ + const SizedBox(height: 8), + _SettingHighlightCard( + icon: _gpsEnabled! ? Icons.gps_fixed : Icons.gps_off, + title: AppLocalizations.of(context)!.gpsModule, + description: 'Onboard GPS hardware', + accentColor: _gpsEnabled! + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: _gpsLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Switch( + value: _gpsEnabled!, + onChanged: _setGpsMode, + ), + ), + const SizedBox(height: 8), + _GpsDiagnosticsCard( + fixValue: _formatGpsFixValue(), + satellitesValue: + _gpsSatelliteCount?.toString() ?? '—', + locationValue: _formatGpsLocationValue(), + lastFixValue: _formatGpsLastFixValue( + AppLocalizations.of(context)!, + ), + ), + ], + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Row( + children: [ + Icon( + Icons.timer_outlined, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'GPS polling is fixed at $_fixedHardwareGpsIntervalSeconds seconds on companion radios.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + if (_advertLocationPolicy == 2) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Saved coordinates', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _CompactCoordinateField( + controller: _latController, + label: AppLocalizations.of(context)!.latitude, + onChanged: (_) => _markPublicInfoDirty(), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _CompactCoordinateField( + controller: _lonController, + label: AppLocalizations.of(context)!.longitude, + onChanged: (_) => _markPublicInfoDirty(), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + onPressed: _useCurrentLocation, + icon: const Icon(Icons.my_location, size: 20), + tooltip: AppLocalizations.of( + context, + )!.useCurrentLocation, + ), + ], + ), + ], + ), + ), + ] else if (_advertLocationPolicy == 1) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Text( + 'Firmware will advertise the live GPS fix when available.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ], + ), + ), + SizedBox(height: 12), + _ConfigSectionCard( + title: AppLocalizations.of(context)!.radioSettings, + subtitle: AppLocalizations.of( + context, + )!.chooseAPresetOrFinetuneCustomRadioSettings, + icon: Icons.settings_input_antenna_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField<_RadioPreset?>( + key: ValueKey(_selectedRadioPreset?.id ?? 'custom'), + initialValue: _selectedRadioPreset, + decoration: InputDecoration( + labelText: 'Radio preset', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + isExpanded: true, + items: [ + DropdownMenuItem<_RadioPreset?>( + value: null, + child: Text(AppLocalizations.of(context)!.custom), + ), + ..._radioPresets.map( + (preset) => DropdownMenuItem<_RadioPreset?>( + value: preset, + child: Text( + preset.label, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onChanged: (preset) { + if (preset == null) { + setState(() { + _selectedRadioPreset = null; + _showCustomRadioSettings = true; + _radioSettingsDirty = true; + _radioSettingsSaved = false; + _radioSettingsError = null; + }); + return; + } + _applyRadioPreset(preset); + _markRadioSettingsDirty(); + }, + ), + const SizedBox(height: 16), + if (_selectedRadioPreset != null) + _SelectedPresetCard(preset: _selectedRadioPreset!), + const SizedBox(height: 8), + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () { + setState(() { + _showCustomRadioSettings = + !_showCustomRadioSettings; + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 4, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Custom settings', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + Text( + _showCustomRadioSettings + ? 'Frequency, bandwidth, SF, and CR' + : 'Tap to fine-tune radio parameters', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + _showCustomRadioSettings + ? Icons.expand_less + : Icons.expand_more, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + if (_showCustomRadioSettings) ...[ + const SizedBox(height: 12), + TextField( + controller: _freqController, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.frequencyMHz, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + isDense: true, + helperText: 'e.g. 869.618', + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + onChanged: (_) { + setState(_syncRadioPresetSelection); + _markRadioSettingsDirty(); + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('bandwidth-$_selectedBandwidth'), + initialValue: _selectedBandwidth, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.bandwidth, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: _bandwidthOptions.map((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _selectedBandwidth = newValue; + _syncRadioPresetSelection(); + }); + _markRadioSettingsDirty(); + } + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey( + 'spreading-factor-$_selectedSpreadingFactor', + ), + initialValue: _selectedSpreadingFactor, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.spreadingFactor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: List.generate(6, (index) => index + 7).map(( + int value, + ) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedSpreadingFactor = newValue; + _syncRadioPresetSelection(); + }); + _markRadioSettingsDirty(); + } + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('coding-rate-$_selectedCodingRate'), + initialValue: _selectedCodingRate, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.codingRate, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: List.generate(4, (index) => index + 5).map(( + int value, + ) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedCodingRate = newValue; + _syncRadioPresetSelection(); + }); + _markRadioSettingsDirty(); + } + }, + ), + ], + const SizedBox(height: 12), + TextField( + controller: _txPowerController, + onChanged: (_) => _markRadioSettingsDirty(), + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.txPowerDbm, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: AppLocalizations.of( + context, + )!.maxPowerDbm(deviceInfo.maxTxPower ?? 22), + ), + keyboardType: TextInputType.number, + ), + if (_selectedPathHashMode != null) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('path-hash-$_selectedPathHashMode'), + initialValue: _selectedPathHashMode, + decoration: InputDecoration( + labelText: 'Advert path hash size', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: + 'Hash size used in adverts and flood paths', + ), + items: [ + DropdownMenuItem( + value: 0, + child: Text(AppLocalizations.of(context)!.oneByteMode0), + ), + DropdownMenuItem( + value: 1, + child: Text(AppLocalizations.of(context)!.twoBytesMode1), + ), + DropdownMenuItem( + value: 2, + child: Text(AppLocalizations.of(context)!.threeBytesMode2), + ), + ], + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedPathHashMode = newValue; + }); + _markRadioSettingsDirty(); + } + }, + ), + ], + if (deviceInfo.clientRepeat != null) ...[ + SizedBox(height: 10), + _SettingHighlightCard( + icon: Icons.repeat_rounded, + title: AppLocalizations.of( + context, + )!.repeatNearbyTraffic, + description: + deviceInfo.allowedRepeatFreqRanges != null && + deviceInfo.allowedRepeatFreqRanges!.isNotEmpty + ? 'Available on: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}' + : 'Help extend range by repeating packets for nearby devices.', + accentColor: colorScheme.secondary, + trailing: Switch( + value: _repeatEnabled, + onChanged: (value) { + setState(() { + _repeatEnabled = value; + }); + _markRadioSettingsDirty(); + }, + ), + ), + ], + const SizedBox(height: 18), + if (_radioSettingsError != null) ...[ + Text( + _radioSettingsError!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + const SizedBox(height: 10), + ], + SizedBox( + width: double.infinity, + child: _SaveActionButton( + onPressed: _isSavingRadioSettings + ? null + : _saveRadioSettings, + isSaving: _isSavingRadioSettings, + isSaved: _radioSettingsSaved, + label: AppLocalizations.of(context)!.saveRadioSettings, + ), + ), + ], + ), + ), + SizedBox(height: 12), + _ConfigSectionCard( + title: AppLocalizations.of(context)!.autoDiscovery, + subtitle: AppLocalizations.of(context)!.howTheRadioAutoAddsDiscoveredNodes, + icon: Icons.person_search_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SettingHighlightCard( + icon: _autoAddDiscoveredContactsEnabled + ? Icons.person_add_alt_1 + : Icons.person_add_disabled, + title: AppLocalizations.of( + context, + )!.enableAutomaticAdding, + description: 'Off = manual-only discovery', + accentColor: _autoAddDiscoveredContactsEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _autoAddDiscoveredContactsEnabled, + onChanged: (value) { + setState(() { + _autoAddDiscoveredContactsEnabled = value; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + ), + const SizedBox(height: 8), + _AutoDiscoveryToggle( + icon: Icons.person_outline_rounded, + label: AppLocalizations.of(context)!.autoaddUsers, + value: _autoAddUsersEnabled, + enabled: _autoAddDiscoveredContactsEnabled, + onChanged: (v) { + setState(() { + _autoAddUsersEnabled = v; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + _AutoDiscoveryToggle( + icon: Icons.router_outlined, + label: AppLocalizations.of(context)!.autoaddRepeaters, + value: _autoAddRepeatersEnabled, + enabled: _autoAddDiscoveredContactsEnabled, + onChanged: (v) { + setState(() { + _autoAddRepeatersEnabled = v; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + _AutoDiscoveryToggle( + icon: Icons.meeting_room_outlined, + label: AppLocalizations.of(context)!.autoaddRoomServers, + value: _autoAddRoomServersEnabled, + enabled: _autoAddDiscoveredContactsEnabled, + onChanged: (v) { + setState(() { + _autoAddRoomServersEnabled = v; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + _AutoDiscoveryToggle( + icon: Icons.sensors_outlined, + label: AppLocalizations.of(context)!.autoaddSensors, + value: _autoAddSensorsEnabled, + enabled: _autoAddDiscoveredContactsEnabled, + onChanged: (v) { + setState(() { + _autoAddSensorsEnabled = v; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + _AutoDiscoveryToggle( + icon: Icons.history_toggle_off_rounded, + label: AppLocalizations.of( + context, + )!.overwriteOldestWhenFull, + value: _overwriteOldestAutoAddEnabled, + enabled: _autoAddDiscoveredContactsEnabled, + onChanged: (v) { + setState(() { + _overwriteOldestAutoAddEnabled = v; + _markAutoDiscoverySettingsDirty(); + }); + }, + ), + const SizedBox(height: 10), + TextField( + controller: _autoAddMaxHopsController, + onChanged: (_) => _markAutoDiscoverySettingsDirty(), + decoration: InputDecoration( + labelText: 'Auto-add max hops', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + filled: true, + isDense: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: '0 = no limit, 1 = direct neighbors only', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + if (_autoDiscoverySettingsError != null) ...[ + Text( + _autoDiscoverySettingsError!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + const SizedBox(height: 8), + ], + SizedBox( + width: double.infinity, + child: _SaveActionButton( + onPressed: _isSavingAutoDiscoverySettings + ? null + : _saveAutoDiscoverySettings, + isSaving: _isSavingAutoDiscoverySettings, + isSaved: _autoDiscoverySettingsSaved, + label: AppLocalizations.of( + context, + )!.saveDiscoverySettings, + ), + ), + ], + ), + ), + SizedBox(height: 12), + _ConfigSectionCard( + title: AppLocalizations.of(context)!.dangerZone, + subtitle: AppLocalizations.of( + context, + )!.destructiveDeviceActions, + icon: Icons.warning_amber_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isClearingContacts || _isClearingChannels + ? null + : _confirmClearAllContacts, + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + minimumSize: const Size.fromHeight(44), + ), + icon: _isClearingContacts + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.people_alt_outlined, size: 20), + label: Text( + AppLocalizations.of(context)!.clearAllContacts, + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isClearingContacts || _isClearingChannels + ? null + : _confirmClearAllChannels, + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + minimumSize: const Size.fromHeight(44), + ), + icon: _isClearingChannels + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.forum_outlined, size: 20), + label: Text( + AppLocalizations.of(context)!.clearAllChannels, + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _isClearingContacts || _isClearingChannels + ? null + : _confirmFactoryReset, + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + minimumSize: const Size.fromHeight(44), + ), + icon: const Icon(Icons.delete_forever_rounded, size: 20), + label: Text( + AppLocalizations.of(context)!.wipeDeviceData, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + + return PopScope( + canPop: !_hasUnsavedChanges, + onPopInvokedWithResult: _handlePopInvoked, + child: scaffold, + ); + } + + String _getDeviceTypeString(BuildContext context, int? deviceType) { + if (deviceType == null) return AppLocalizations.of(context)!.unknown; + switch (deviceType) { + case 0: + return AppLocalizations.of(context)!.noneUnknown; + case 1: + return AppLocalizations.of(context)!.chatNode; + case 2: + return AppLocalizations.of(context)!.repeater; + case 3: + return AppLocalizations.of(context)!.roomChannel; + default: + return AppLocalizations.of(context)!.typeNumber(deviceType); + } + } + + String _formatStorage(int storageKb) { + if (storageKb >= 1024 * 1024) { + return '${(storageKb / (1024 * 1024)).toStringAsFixed(2)} GB'; + } + if (storageKb >= 1024) { + return '${(storageKb / 1024).toStringAsFixed(1)} MB'; + } + return '$storageKb KB'; + } + + String _formatBlePin(int? blePin) { + if (blePin == null) { + return AppLocalizations.of(context)!.unknown; + } + return blePin.toString().padLeft(6, '0'); + } + + String _pathHashModeLabel(int mode) { + switch (mode) { + case 0: + return '1 byte'; + case 1: + return '2 bytes'; + case 2: + return '3 bytes'; + default: + return 'Mode $mode'; + } + } +} + +class _ConfigHeroCard extends StatelessWidget { + final String title; + final String subtitle; + final List<_HeroStatData> stats; + + const _ConfigHeroCard({ + required this.title, + required this.subtitle, + required this.stats, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.tune_rounded, + color: colorScheme.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + color: colorScheme.onSurface, + ), + ), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: stats.map(_HeroStat.new).toList(), + ), + ], + ), + ); + } +} + +class _HeroStatData { + final String label; + final String value; + final IconData icon; + final bool emphasized; + + const _HeroStatData({ + required this.label, + required this.value, + required this.icon, + this.emphasized = false, + }); +} + +class _HeroStat extends StatelessWidget { + final _HeroStatData data; + + const _HeroStat(this.data); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final iconColor = data.emphasized + ? colorScheme.primary + : colorScheme.onSurfaceVariant; + final borderColor = data.emphasized + ? colorScheme.primary + : colorScheme.outlineVariant; + + return Container( + constraints: const BoxConstraints(minWidth: 120), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + Icon(data.icon, size: 16, color: iconColor), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + Text( + data.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ConfigSectionCard extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final Widget child; + + const _ConfigSectionCard({ + required this.title, + required this.subtitle, + required this.icon, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + color: colorScheme.surfaceContainerLow, + elevation: 0, + surfaceTintColor: Colors.transparent, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: colorScheme.primary, size: 22), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } +} + +class _ConfigDropdownField extends StatelessWidget { + final String label; + final T value; + final List> items; + final ValueChanged onChanged; + + const _ConfigDropdownField({ + required this.label, + required this.value, + required this.items, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return DropdownButtonFormField( + initialValue: value, + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: items, + onChanged: onChanged, + ); + } +} + +class _StorageStat extends StatelessWidget { + final String label; + final String value; + final bool compact; + + const _StorageStat({ + required this.label, + required this.value, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: EdgeInsets.all(compact ? 8 : 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + fontSize: compact ? 11 : 12, + ), + ), + Text( + value, + style: TextStyle( + fontWeight: FontWeight.w800, + color: colorScheme.onSurface, + fontSize: compact ? 14 : 15, + ), + ), + ], + ), + ); + } +} + +class _CompactCoordinateField extends StatelessWidget { + final TextEditingController controller; + final String label; + final ValueChanged? onChanged; + + const _CompactCoordinateField({ + required this.controller, + required this.label, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(18)), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerLowest, + isDense: true, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + ); + } +} + +class _StorageUsageMeter extends StatelessWidget { + final DeviceInfo deviceInfo; + + const _StorageUsageMeter({required this.deviceInfo}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final percent = ((deviceInfo.storageUsedPercent ?? 0) / 100).clamp( + 0.0, + 1.0, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: percent, + minHeight: 8, + backgroundColor: colorScheme.surface, + ), + ), + const SizedBox(height: 4), + Text( + '${(deviceInfo.storageUsedPercent ?? 0).toStringAsFixed(0)}% used', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +class _SelectedPresetCard extends StatelessWidget { + final _RadioPreset preset; + + const _SelectedPresetCard({required this.preset}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + preset.label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + preset.summary, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +class _SaveActionButton extends StatelessWidget { + final VoidCallback? onPressed; + final bool isSaving; + final bool isSaved; + final String label; + + const _SaveActionButton({ + required this.onPressed, + required this.isSaving, + required this.isSaved, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return FilledButton( + onPressed: onPressed, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: isSaving + ? Row( + key: const ValueKey('saving'), + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2.2), + ), + const SizedBox(width: 10), + Text(AppLocalizations.of(context)!.saving), + ], + ) + : Row( + key: ValueKey(isSaved ? 'saved' : 'idle'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon(isSaved ? Icons.check_rounded : Icons.save_outlined), + const SizedBox(width: 8), + Text(isSaved ? 'Saved' : label), + ], + ), + ), + ); + } +} + +class _SettingHighlightCard extends StatelessWidget { + final IconData icon; + final String title; + final String description; + final Color accentColor; + final Widget trailing; + + const _SettingHighlightCard({ + required this.icon, + required this.title, + required this.description, + required this.accentColor, + required this.trailing, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Row( + children: [ + Icon(icon, color: accentColor, size: 22), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + description, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + trailing, + ], + ), + ); + } +} + +class _GpsDiagnosticsCard extends StatelessWidget { + final String fixValue; + final String satellitesValue; + final String locationValue; + final String lastFixValue; + + const _GpsDiagnosticsCard({ + required this.fixValue, + required this.satellitesValue, + required this.locationValue, + required this.lastFixValue, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.location_searching_rounded, + color: colorScheme.primary, + size: 16, + ), + const SizedBox(width: 6), + Text( + 'GPS diagnostics', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 6), + _GpsDiagnosticsRow(label: AppLocalizations.of(context)!.fix, value: fixValue), + const SizedBox(height: 4), + _GpsDiagnosticsRow(label: AppLocalizations.of(context)!.satellites, value: satellitesValue), + const SizedBox(height: 4), + _GpsDiagnosticsRow(label: AppLocalizations.of(context)!.lastFix, value: lastFixValue), + const SizedBox(height: 4), + _GpsDiagnosticsRow( + label: AppLocalizations.of(context)!.location, + value: locationValue, + maxLines: 2, + ), + ], + ), + ); + } +} + +class _GpsDiagnosticsRow extends StatelessWidget { + final String label; + final String value; + final int maxLines; + + const _GpsDiagnosticsRow({ + required this.label, + required this.value, + this.maxLines = 1, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 84, + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + value, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + } +} + +class _AutoDiscoveryToggle extends StatelessWidget { + final IconData icon; + final String label; + final bool value; + final bool enabled; + final ValueChanged onChanged; + + const _AutoDiscoveryToggle({ + required this.icon, + required this.label, + required this.value, + required this.enabled, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return SwitchListTile( + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + secondary: Icon(icon, size: 20), + title: Text(label), + value: value, + onChanged: enabled ? onChanged : null, + ); + } +} + +class _RadioPreset { + final String id; + final String label; + final String summary; + final int frequencyKhz; + final int bandwidth; + final int spreadingFactor; + final int codingRate; + + const _RadioPreset({ + required this.id, + required this.label, + required this.summary, + required this.frequencyKhz, + required this.bandwidth, + required this.spreadingFactor, + required this.codingRate, + }); +} diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart new file mode 100644 index 0000000..1d2347b --- /dev/null +++ b/lib/screens/discovery_screen.dart @@ -0,0 +1,1077 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'dart:typed_data'; + +import '../l10n/app_localizations.dart'; +import '../models/contact.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../services/mesh_map_nodes_service.dart'; +import '../widgets/compact_signal_indicator.dart' show SignalMetric; + +enum _DiscoveryListFilter { all, repeaters, sensors, others } + +class DiscoveryScreen extends StatefulWidget { + final bool autoDiscoverRepeatersOnOpen; + + const DiscoveryScreen({super.key, this.autoDiscoverRepeatersOnOpen = false}); + + @override + State createState() => _DiscoveryScreenState(); +} + +class _DiscoveryScreenState extends State { + static const int _repeaterAdvertType = 2; + static const int _sensorAdvertType = 4; + final Set _resolvingAdvertKeys = {}; + final Set _runningDiscoveryTypes = {}; + final TextEditingController _searchController = TextEditingController(); + bool _isResolvingAll = false; + bool _hasQueuedAutoRepeaterDiscovery = false; + String _searchQuery = ''; + _DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all; + late final Future> _cachedNodesFuture; + + @override + void initState() { + super.initState(); + _cachedNodesFuture = MeshMapNodesService.loadCachedNodes(); + + if (widget.autoDiscoverRepeatersOnOpen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _autoDiscoverRepeaters(); + }); + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _clearAllDiscoveries() async { + final pendingCount = context.read().pendingAdverts.length; + if (pendingCount == 0) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(context)!.clearDiscoveries), + content: Text( + 'Remove all $pendingCount pending discover${pendingCount == 1 ? 'y' : 'ies'} from this device?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(AppLocalizations.of(context)!.clearAllLabel), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + await context.read().clearPendingAdverts(); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries), + ), + ); + } + + Future _discoverNodeType(int advertType) async { + if (_runningDiscoveryTypes.contains(advertType)) return; + + setState(() { + _runningDiscoveryTypes.add(advertType); + }); + + try { + final connectionProvider = context.read(); + connectionProvider.clearError(); + await connectionProvider.discoverNodeType(advertType: advertType); + if (!mounted) return; + final discoveryError = connectionProvider.error; + if (discoveryError != null) { + connectionProvider.clearError(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(discoveryError), + backgroundColor: Colors.red, + ), + ); + return; + } + final l10n = AppLocalizations.of(context)!; + final label = switch (advertType) { + _repeaterAdvertType => l10n.repeaterDiscoverySent, + _sensorAdvertType => l10n.sensorDiscoverySent, + _ => 'Discovery sent', + }; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(label))); + } finally { + if (mounted) { + setState(() { + _runningDiscoveryTypes.remove(advertType); + }); + } + } + } + + Future _autoDiscoverRepeaters() async { + if (_hasQueuedAutoRepeaterDiscovery) { + return; + } + _hasQueuedAutoRepeaterDiscovery = true; + await _discoverNodeType(_repeaterAdvertType); + } + + Future _resolveAdvert(PendingAdvert advert) async { + final keyHex = advert.publicKeyHex; + if (_resolvingAdvertKeys.contains(keyHex)) return; + + setState(() { + _resolvingAdvertKeys.add(keyHex); + }); + + try { + final connectionProvider = context.read(); + final contactsProvider = context.read(); + + final canAddDirectly = _canAddPendingAdvertDirectly(advert); + if (canAddDirectly) { + final added = await _addPendingAdvertToRadio( + advert, + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + ); + if (!added) { + return; + } + } else { + connectionProvider.clearError(); + await connectionProvider.getContact(advert.publicKey); + final resolveError = connectionProvider.error; + if (resolveError == 'Not found') { + connectionProvider.clearError(); + final added = await _addPendingAdvertToRadio( + advert, + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + ); + if (!added) { + return; + } + } else if (resolveError != null) { + // Other failures (timeout, disconnect, ...): surface the error and + // leave the advert pending so the user can retry. + connectionProvider.clearError(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.failedToAddContact(resolveError), + ), + backgroundColor: Colors.red, + ), + ); + } + return; + } + } + if ((advert.typeValue ?? 0) == _sensorAdvertType) { + await connectionProvider.requestTelemetry(advert.publicKey); + } + } finally { + if (mounted) { + setState(() { + _resolvingAdvertKeys.remove(keyHex); + }); + } + } + } + + Future _resolveAll(List adverts) async { + if (_isResolvingAll || adverts.isEmpty) return; + + setState(() { + _isResolvingAll = true; + }); + + try { + final contactsProvider = context.read(); + for (final advert in adverts) { + if (!mounted) break; + // Skip already-resolved contacts + if (contactsProvider.findContactByKey(advert.publicKey) != null) { + continue; + } + await _resolveAdvert(advert); + } + } finally { + if (mounted) { + setState(() { + _isResolvingAll = false; + }); + } + } + } + + String _formatRelativeTime(BuildContext context, DateTime when) { + final l10n = AppLocalizations.of(context)!; + final diff = DateTime.now().difference(when); + if (diff.inMinutes < 1) return l10n.justNow; + if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); + if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours); + return l10n.daysAgo(diff.inDays); + } + + Contact _contactFromPendingAdvert(PendingAdvert advert) { + final lastAdvert = + advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000); + final advName = advert.advName?.trim().isNotEmpty == true + ? advert.advName!.trim() + : advert.shortDisplayKey; + + return Contact( + publicKey: Uint8List.fromList(advert.publicKey), + type: ContactType.fromValue(advert.typeValue ?? 0), + flags: advert.flags ?? 0, + outPathLen: advert.signedEncodedPathLen ?? -1, + outPath: advert.paddedPathBytes == null + ? Uint8List(64) + : Uint8List.fromList(advert.paddedPathBytes!), + advName: advName, + lastAdvert: lastAdvert, + advLat: advert.advLat ?? 0, + advLon: advert.advLon ?? 0, + lastMod: lastAdvert, + ); + } + + bool _canAddPendingAdvertDirectly(PendingAdvert advert) { + return advert.publicKey.length == 32 && + advert.typeValue != null && + advert.typeValue != 0; + } + + Future _addPendingAdvertToRadio( + PendingAdvert advert, { + required ConnectionProvider connectionProvider, + required ContactsProvider contactsProvider, + }) async { + final contact = _contactFromPendingAdvert(advert); + connectionProvider.clearError(); + await connectionProvider.addOrUpdateContact(contact); + + final addError = connectionProvider.error; + if (addError != null) { + connectionProvider.clearError(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.failedToAddContact(addError.toString()))), + ); + } + return false; + } + + contactsProvider.addOrUpdateContact( + contact, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + return true; + } + + String _displayNameForAdvert( + PendingAdvert advert, + ContactsProvider contactsProvider, + List cachedNodes, + ) { + final advertisedName = advert.advName?.trim(); + if (advertisedName != null && advertisedName.isNotEmpty) { + return advertisedName; + } + + Contact? existingMatch; + for (final contact in contactsProvider.contacts) { + if (contact.publicKeyHex == advert.publicKeyHex) { + existingMatch = contact; + break; + } + } + if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) { + return existingMatch.displayName; + } + + MeshMapNode? cachedMatch; + for (final node in cachedNodes) { + if (node.publicKey == advert.publicKeyHex.toLowerCase()) { + cachedMatch = node; + break; + } + } + if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) { + return cachedMatch.name; + } + + return advert.shortDisplayKey; + } + + IconData _iconForAdvert(PendingAdvert advert) { + return switch (advert.typeValue) { + _repeaterAdvertType => Icons.router_outlined, + _sensorAdvertType => Icons.sensors_outlined, + _ => Icons.campaign_outlined, + }; + } + + String? _typeLabelForValue(int? typeValue) { + return switch (typeValue) { + _repeaterAdvertType => 'Repeater', + _sensorAdvertType => 'Sensor', + 3 => 'Room', + 1 => 'Chat', + _ => null, + }; + } + + int? _resolvedTypeValueForAdvert( + PendingAdvert advert, + List cachedNodes, + ) { + 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 => _repeaterAdvertType, + 4 => _sensorAdvertType, + 3 => 3, + 2 => 1, + _ => null, + }; + } + } + + return null; + } + + String? _resolvedTypeLabelForAdvert( + PendingAdvert advert, + List cachedNodes, + ) { + return _typeLabelForValue(_resolvedTypeValueForAdvert(advert, cachedNodes)); + } + + bool get _hasActiveInlineFilter => + _searchQuery.trim().isNotEmpty || + _selectedFilter != _DiscoveryListFilter.all; + + bool _matchesSelectedFilter( + PendingAdvert advert, + List 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 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 _filteredPendingAdverts( + List adverts, + ContactsProvider contactsProvider, + List 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, + String? subtitle, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (subtitle != null && subtitle.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar(title: Text(l10n.discovery)), + body: FutureBuilder>( + future: _cachedNodesFuture, + builder: (context, nodesSnapshot) => + Consumer2( + builder: (context, contactsProvider, connectionProvider, child) { + final pendingAdverts = contactsProvider.pendingAdverts; + final isConnected = connectionProvider.deviceInfo.isConnected; + final cachedNodes = nodesSnapshot.data ?? const []; + final repeatersBusy = _runningDiscoveryTypes.contains( + _repeaterAdvertType, + ); + final sensorsBusy = _runningDiscoveryTypes.contains( + _sensorAdvertType, + ); + + // Track which pending adverts are already in the contacts list + final resolvedKeySet = {}; + for (final advert in pendingAdverts) { + if (contactsProvider.findContactByKey(advert.publicKey) != + null) { + resolvedKeySet.add(advert.publicKeyHex); + } + } + + 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, + 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: 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 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, + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildPendingAdvertCard( + BuildContext context, { + required PendingAdvert advert, + required ContactsProvider contactsProvider, + required bool isConnected, + required bool isResolved, + required List cachedNodes, + required AppLocalizations l10n, + }) { + final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex); + final displayName = _displayNameForAdvert( + advert, + contactsProvider, + cachedNodes, + ); + final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes); + final downMetric = SignalMetric.fromValues( + rssiDbm: advert.rxRssiDbm, + snrDb: advert.rxSnr, + ); + final upMetric = SignalMetric.fromValues( + rssiDbm: advert.repeaterLastRssi, + snrDb: advert.repeaterLastSnr, + ); + final detailLines = [ + '${l10n.publicKey}: ${advert.shortDisplayKey}', + ]; + final summaryParts = []; + final battery = advert.repeaterBatteryPercent; + if (battery != null) { + summaryParts.add('Battery ${battery.round()}%'); + } + if (advert.repeaterQueueLen != null) { + summaryParts.add('Queue ${advert.repeaterQueueLen}'); + } + if (summaryParts.isNotEmpty) { + detailLines.add(summaryParts.join(' • ')); + } + detailLines.add( + '${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}', + ); + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar(child: Icon(_iconForAdvert(advert))), + const SizedBox(width: 12), + Expanded( + child: _buildAdvertTitle( + context, + displayName: displayName, + subtitle: typeLabel, + ), + ), + if (downMetric != null || upMetric != null) ...[ + const SizedBox(width: 12), + _buildSignalSummary( + context, + downMetric: downMetric, + upMetric: upMetric, + ), + ], + const SizedBox(width: 8), + if (isResolved) + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + size: 24, + ) + else if (isResolving) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.person_add_alt_1), + tooltip: AppLocalizations.of(context)!.resolveContact, + onPressed: isConnected + ? () => _resolveAdvert(advert) + : null, + ), + ], + ), + const SizedBox(height: 10), + Text( + detailLines.join('\n'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + } + + 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, + SignalMetric? upMetric, + }) { + if (downMetric == null && upMetric == null) { + return const SizedBox.shrink(); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (downMetric != null) + _buildDirectionalSignal( + context, + icon: Icons.south_west_rounded, + metric: downMetric, + ), + if (downMetric != null && upMetric != null) const SizedBox(height: 6), + if (upMetric != null) + _buildDirectionalSignal( + context, + icon: Icons.north_east_rounded, + metric: upMetric, + ), + ], + ); + } + + Widget _buildDirectionalSignal( + BuildContext context, { + required IconData icon, + required SignalMetric metric, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + icon, + size: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 3), + _buildMiniSignalBars(context, metric), + const SizedBox(width: 4), + Text( + metric.valueLabel, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ); + } + + Widget _buildMiniSignalBars(BuildContext context, SignalMetric metric) { + final inactive = Theme.of(context).colorScheme.outlineVariant; + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var index = 0; index < 3; index++) ...[ + if (index > 0) const SizedBox(width: 2), + Container( + width: 3, + height: 6.0 + (index * 4), + decoration: BoxDecoration( + color: index < metric.activeBars ? metric.color : inactive, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ], + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..8c0cdd1 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -0,0 +1,1173 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../providers/connection_provider.dart'; +import '../providers/app_provider.dart'; +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'; +import 'discovery_screen.dart'; +import 'sensors_tab.dart'; +import 'map_tab.dart'; +import 'repeaters_map_screen.dart'; +import 'settings_screen.dart'; +import 'device_config_screen.dart'; +import 'packet_log_screen.dart'; +import 'live_traffic_screen.dart'; +import 'profiles_screen.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/mesh_map_nodes_service.dart'; +import '../services/profile_device_key_resolver.dart'; +import '../services/profile_manager.dart'; +import '../services/profile_workspace_coordinator.dart'; +import '../services/profiles_feature_service.dart'; + +enum _HomeTab { messages, contacts, sensors, map } + +class HomeScreen extends StatefulWidget { + final Function(AppThemeMode) onThemeChanged; + final Function(Locale?) onLocaleChanged; + final AppThemeMode currentTheme; + final Locale? currentLocale; + final bool shouldShowPermissionDialog; + + const HomeScreen({ + super.key, + required this.onThemeChanged, + required this.onLocaleChanged, + required this.currentTheme, + required this.currentLocale, + this.shouldShowPermissionDialog = false, + }); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State + with TickerProviderStateMixin, WidgetsBindingObserver { + late TabController _tabController; + late final AppProvider _appProvider; + late final ConnectionProvider _connectionProvider; + int _currentIndex = 0; + bool _isMapFullscreen = false; + bool _showRxTxIndicators = true; + bool _isMapEnabled = true; + bool _isContactsEnabled = true; + bool _isSensorsEnabled = false; + bool _isSimpleMode = false; + AppLifecycleState _lifecycleState = AppLifecycleState.resumed; + String? _lastProfileDeviceKey; + Timer? _sensorAutoRefreshTicker; + + List<_HomeTab> get _enabledTabs { + if (_isSimpleMode) { + return const [_HomeTab.messages, _HomeTab.map]; + } + return [ + _HomeTab.messages, + if (_isContactsEnabled) _HomeTab.contacts, + if (_isSensorsEnabled) _HomeTab.sensors, + if (_isMapEnabled) _HomeTab.map, + ]; + } + + _HomeTab get _currentTab { + final tabs = _enabledTabs; + final safeIndex = _currentIndex < tabs.length + ? _currentIndex + : tabs.length - 1; + return tabs[safeIndex < 0 ? 0 : safeIndex]; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _appProvider = context.read(); + _connectionProvider = context.read(); + _connectionProvider.addListener(_handleConnectionProviderChanged); + _isMapEnabled = _appProvider.isMapEnabled; + _isContactsEnabled = _appProvider.isContactsEnabled; + _isSensorsEnabled = _appProvider.isSensorsEnabled; + _isSimpleMode = _appProvider.isSimpleMode; + _appProvider.addListener(_handleAppProviderChanged); + + // Initialize synchronously so first build always has a valid controller. + _initTabController(); + _loadRxTxPreference(); + MeshMapNodesService.syncInBackgroundIfStale(); + + // Show permission dialog after the first frame if needed + if (widget.shouldShowPermissionDialog) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _showPermissionDialog(); + }); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleConnectionProviderChanged(); + }); + _configureSensorAutoRefreshTicker(); + } + + void _initTabController() { + _tabController = TabController(length: _enabledTabs.length, vsync: this); + _tabController.addListener(_onTabChanged); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _handleTabActivated(_currentTab); + }); + } + + void _handleAppProviderChanged() { + if (!mounted) return; + _updateTabController( + mapEnabled: _appProvider.isMapEnabled, + contactsEnabled: _appProvider.isContactsEnabled, + sensorsEnabled: _appProvider.isSensorsEnabled, + simpleMode: _appProvider.isSimpleMode, + ); + } + + void _onTabChanged() { + final previousTab = _currentTab; + final nextIndex = _tabController.index; + + setState(() { + _currentIndex = nextIndex; + if (_currentTab != _HomeTab.map) { + _isMapFullscreen = false; + } + }); + + final nextTab = _currentTab; + if (previousTab != nextTab) { + _handleTabActivated(nextTab); + } + } + + void _updateTabController({ + required bool mapEnabled, + required bool contactsEnabled, + required bool sensorsEnabled, + required bool simpleMode, + }) { + if (_isMapEnabled == mapEnabled && + _isContactsEnabled == contactsEnabled && + _isSensorsEnabled == sensorsEnabled && + _isSimpleMode == simpleMode) { + return; + } + + final oldTabs = _enabledTabs; + final oldIndex = oldTabs.isEmpty + ? 0 + : _tabController.index.clamp(0, oldTabs.length - 1); + final oldTab = oldTabs[oldIndex]; + + final oldController = _tabController; + oldController.removeListener(_onTabChanged); + oldController.dispose(); + + // Update state + _isMapEnabled = mapEnabled; + _isContactsEnabled = contactsEnabled; + _isSensorsEnabled = sensorsEnabled; + _isSimpleMode = simpleMode; + if (!_isMapEnabled) { + _isMapFullscreen = false; + } + _configureSensorAutoRefreshTicker(); + + final newTabs = _enabledTabs; + final newIndex = newTabs.indexOf(oldTab); + + // Create new controller + _tabController = TabController(length: newTabs.length, vsync: this); + _tabController.addListener(_onTabChanged); + + _currentIndex = newIndex >= 0 ? newIndex : 0; + _tabController.index = _currentIndex; + + setState(() {}); + _handleTabActivated(_currentTab); + } + + void _navigateToTab(_HomeTab tab) { + final targetIndex = _enabledTabs.indexOf(tab); + if (targetIndex >= 0 && targetIndex != _tabController.index) { + _tabController.animateTo(targetIndex); + } + } + + void _handleTabActivated(_HomeTab tab) { + _syncFastLocationUiState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + switch (tab) { + case _HomeTab.messages: + break; + case _HomeTab.contacts: + context.read().markAllAsViewed(); + break; + case _HomeTab.sensors: + break; + case _HomeTab.map: + break; + } + }); + } + + void _syncFastLocationUiState() { + final isActiveTab = + _currentTab == _HomeTab.map || _currentTab == _HomeTab.messages; + _appProvider.setFastLocationUiActive( + _lifecycleState == AppLifecycleState.resumed && isActiveTab, + ); + } + + void _handleConnectionProviderChanged() { + final deviceKey = ProfileDeviceKeyResolver.resolve( + deviceInfo: _connectionProvider.deviceInfo, + connectionMode: _connectionProvider.connectionMode, + ); + if (_lastProfileDeviceKey == deviceKey) { + return; + } + _lastProfileDeviceKey = deviceKey; + if (deviceKey == null || !mounted) { + return; + } + unawaited( + context + .read() + .syncActiveProfileForCurrentDevice(), + ); + } + + @override + void dispose() { + _sensorAutoRefreshTicker?.cancel(); + _connectionProvider.removeListener(_handleConnectionProviderChanged); + WidgetsBinding.instance.removeObserver(this); + _appProvider.setFastLocationUiActive(false); + _appProvider.removeListener(_handleAppProviderChanged); + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + super.dispose(); + } + + void _openLiveTraffic(ConnectionProvider provider) { + openLiveTrafficScreen(context, provider); + } + + @override + 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 _runSensorAutoRefreshTick() async { + if (!mounted || + !_isSensorsEnabled || + _lifecycleState != AppLifecycleState.resumed) { + return; + } + + final sensorsProvider = context.read(); + final contactsProvider = context.read(); + final connectionProvider = context.read(); + sensorsProvider.clearExpiredRefreshStates(); + await sensorsProvider.refreshDueSensors( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + now: DateTime.now(), + ); + await sensorsProvider.captureTrackedTelemetryHistory( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + } + + Future _loadRxTxPreference() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + setState(() { + _showRxTxIndicators = + prefs.getBool( + ProfileStorageScope.scopedKey('show_rx_tx_indicators'), + ) ?? + true; + }); + } + } + + void _showPermissionDialog() { + if (!mounted) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => PermissionRequestDialog( + onPermissionsGranted: () { + debugPrint('✅ Location permissions granted'); + }, + onPermissionsDenied: () { + debugPrint('⚠️ Location permissions denied'); + // Show a snackbar to inform the user + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.locationPermissionRequired, + ), + duration: const Duration(seconds: 5), + ), + ); + } + }, + ), + ); + } + + void _showDeviceInfoSheet(BuildContext context, DeviceInfo deviceInfo) { + // Request self telemetry so it's fresh + context.read().requestSelfTelemetry(); + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => Consumer( + 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( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var i = 0; i < 5; i++) ...[ + if (i > 0) const SizedBox(width: 1.5), + Container( + width: 2.5, + height: 4.0 + (i * 2), + decoration: BoxDecoration( + color: i < activeBars ? color : inactive, + borderRadius: BorderRadius.circular(1.5), + ), + ), + ], + ], + ); + } + + Widget _buildCompactActivityIndicator({ + required bool rxActive, + required bool txActive, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: rxActive ? Colors.green : Colors.grey, + ), + ), + const SizedBox(width: 6), + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: txActive ? Colors.blue : Colors.grey, + ), + ), + ], + ), + ); + } + + Future _showConnectionDialog(BuildContext context) async { + await showConnectionDialogFlow( + context, + backgroundColor: Colors.transparent, + ); + } + + @override + Widget build(BuildContext context) { + // Set localizations for notifications + final messagesProvider = context.read(); + final localizations = AppLocalizations.of(context); + if (localizations != null) { + messagesProvider.setLocalizations(localizations); + } + + context.watch(); + final activeProfileId = context.select( + (manager) => manager.activeProfileId, + ); + + final enabledTabs = _enabledTabs; + final isMapTabActive = _currentTab == _HomeTab.map; + final shouldHideUI = _isMapEnabled && _isMapFullscreen && isMapTabActive; + final shouldShowTabBar = enabledTabs.length > 1; + + return Scaffold( + appBar: shouldHideUI + ? null + : AppBar( + toolbarHeight: 64, + titleSpacing: 8, + title: _buildCompactStatusBar(), + actions: [ + Consumer( + builder: (context, provider, child) { + final isConnected = + provider.deviceInfo.isConnected || + provider.deviceInfo.isConnected; + if (isConnected) { + return IconButton( + onPressed: () async { + await provider.disconnect(); + }, + icon: Icon(Icons.power_settings_new), + tooltip: AppLocalizations.of(context)!.disconnect, + color: Colors.red.shade700, + ); + } + return const SizedBox.shrink(); + }, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + itemBuilder: (context) { + final items = >[]; + final profilesEnabled = + !_isSimpleMode && + context.read().profilesEnabled; + + if (!_isSimpleMode) { + items.add( + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.radar_outlined), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.liveTraffic), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + final provider = context.read(); + Future.delayed(Duration.zero, () { + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (_) => + LiveTrafficScreen.fromProvider( + provider, + openPacketLogs: () { + navigator.push( + MaterialPageRoute( + builder: (_) => PacketLogScreen( + bleService: provider.bleService, + ), + ), + ); + }, + ), + ), + ); + }); + }, + ), + ); + + items.add( + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.router_outlined), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.repeatersMap), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + Future.delayed(Duration.zero, () { + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => + const RepeatersMapScreen(), + fullscreenDialog: true, + ), + ); + }); + }, + ), + ); + + items.add( + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.person_search), + const SizedBox(width: 8), + Consumer( + builder: (context, contactsProvider, child) { + final pendingCount = + contactsProvider.pendingAdverts.length; + return Text( + pendingCount > 0 + ? 'Discovery ($pendingCount)' + : 'Discovery', + ); + }, + ), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + Future.delayed(Duration.zero, () { + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => const DiscoveryScreen(), + ), + ); + }); + }, + ), + ); + } + + items.add( + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.settings), + SizedBox(width: 8), + Text(AppLocalizations.of(context)!.settings), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + Future.delayed(Duration.zero, () async { + if (!mounted) return; + await navigator.push( + MaterialPageRoute( + builder: (context) => SettingsScreen( + onThemeChanged: widget.onThemeChanged, + onLocaleChanged: widget.onLocaleChanged, + currentTheme: widget.currentTheme, + currentLocale: widget.currentLocale, + ), + ), + ); + _loadRxTxPreference(); + }); + }, + ), + ); + + if (profilesEnabled) { + items.add( + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.layers_outlined), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.profiles), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + Future.delayed(Duration.zero, () { + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => const ProfilesScreen(), + ), + ); + }); + }, + ), + ); + } + + return items; + }, + ), + ], + ), + body: TabBarView( + controller: _tabController, + children: enabledTabs.map((tab) { + switch (tab) { + case _HomeTab.messages: + return MessagesTab( + isActive: + _currentTab == _HomeTab.messages && + _lifecycleState == AppLifecycleState.resumed, + onNavigateToMap: _isMapEnabled + ? () => _navigateToTab(_HomeTab.map) + : null, + ); + case _HomeTab.contacts: + return ContactsTab( + onNavigateToMap: _isMapEnabled + ? () => _navigateToTab(_HomeTab.map) + : null, + onNavigateToMessages: () => _navigateToTab(_HomeTab.messages), + ); + case _HomeTab.sensors: + return SensorsTab( + isActive: + _currentTab == _HomeTab.sensors && + _lifecycleState == AppLifecycleState.resumed, + ); + case _HomeTab.map: + return MapTab( + key: ValueKey('map:$activeProfileId'), + onFullscreenChanged: (isFullscreen) { + setState(() { + _isMapFullscreen = isFullscreen; + }); + }, + onNavigateToMessages: () => _navigateToTab(_HomeTab.messages), + ); + } + }).toList(), + ), + bottomNavigationBar: shouldHideUI || !shouldShowTabBar + ? null + : Consumer2( + builder: (context, messagesProvider, contactsProvider, child) { + final unreadCount = messagesProvider.unreadCount; + final newContactsCount = contactsProvider.newContactsCount; + + return Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: TabBar( + controller: _tabController, + onTap: (index) { + final tabs = _enabledTabs; + if (index < 0 || index >= tabs.length) { + return; + } + _handleTabActivated(tabs[index]); + }, + tabs: enabledTabs.map((tab) { + switch (tab) { + case _HomeTab.messages: + return Tab( + icon: _buildTabIconWithBadge( + Icons.message, + unreadCount, + ), + text: AppLocalizations.of(context)!.chat, + ); + case _HomeTab.contacts: + return Tab( + icon: _buildTabIconWithBadge( + Icons.contacts, + newContactsCount, + ), + text: AppLocalizations.of(context)!.contacts, + ); + case _HomeTab.map: + return Tab( + icon: Icon(Icons.map), + text: AppLocalizations.of(context)!.map, + ); + case _HomeTab.sensors: + return Tab( + icon: const Icon(Icons.sensors), + text: AppLocalizations.of(context)!.sensors, + ); + } + }).toList(), + ), + ); + }, + ), + ); + } + + Widget _buildCompactStatusBar() { + return Consumer( + builder: (context, provider, child) { + final deviceInfo = provider.deviceInfo; + final isConnected = deviceInfo.isConnected; + final isTcpConnected = provider.connectionMode == ConnectionMode.tcp; + + if (!isConnected) { + final buttonLabel = provider.isReconnecting + ? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}' + : AppLocalizations.of(context)!.connect; + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context)!.appTitle, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + provider.isReconnecting + ? AppLocalizations.of(context)!.restoringPreviousLink + : AppLocalizations.of(context)!.noDeviceConnected, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + FilledButton.icon( + onPressed: provider.isReconnecting + ? null + : () => _showConnectionDialog(context), + icon: provider.isReconnecting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white70, + ), + ), + ) + : const Icon(Icons.add_link_rounded, size: 18), + label: Text(buttonLabel), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + if (provider.isReconnecting) ...[ + const SizedBox(width: 8), + IconButton( + onPressed: () => provider.cancelReconnection(), + icon: Icon(Icons.close, size: 20), + tooltip: AppLocalizations.of(context)!.cancelReconnection, + style: IconButton.styleFrom( + backgroundColor: Colors.red.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.all(8), + ), + ), + ], + ], + ); + } + + final theme = Theme.of(context); + final subtitleColor = theme.colorScheme.onSurfaceVariant; + final signalColor = isTcpConnected + ? Colors.green + : (deviceInfo.signalRssi != null + ? BatteryDisplayHelper.getSignalColor(deviceInfo.signalRssi!) + : Colors.grey); + + return LayoutBuilder( + builder: (context, constraints) { + final isTight = constraints.maxWidth < 360; + + return Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(22), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + fit: FlexFit.loose, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + deviceInfo.selfName ?? + AppLocalizations.of(context)!.appTitle, + style: + (isTight + ? theme.textTheme.titleSmall + : theme.textTheme.titleMedium) + ?.copyWith(fontWeight: FontWeight.w700), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + 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 (!isTcpConnected && + deviceInfo.signalRssi != null) ...[ + const SizedBox(width: 4), + _buildMiniSignalBars( + activeBars: + BatteryDisplayHelper.getSignalBars( + deviceInfo.signalRssi!, + ), + color: signalColor, + ), + ], + if (deviceInfo.batteryPercent != null) ...[ + const SizedBox(width: 8), + Icon( + BatteryDisplayHelper.getBatteryIcon( + deviceInfo.batteryPercent!, + ), + size: 13, + color: + BatteryDisplayHelper.getBatteryColor( + deviceInfo.batteryPercent!, + ), + ), + const SizedBox(width: 2), + Text( + '${deviceInfo.batteryPercent!.round()}%', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: + BatteryDisplayHelper.getBatteryColor( + deviceInfo.batteryPercent!, + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + const SizedBox(width: 4), + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const DeviceConfigScreen(), + ), + ); + }, + onLongPress: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PacketLogScreen( + bleService: provider.bleService, + ), + ), + ); + }, + tooltip: AppLocalizations.of(context)!.settings, + icon: const Icon(Icons.tune_rounded), + color: subtitleColor, + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.surface, + foregroundColor: subtitleColor, + minimumSize: Size.square(isTight ? 38 : 40), + padding: EdgeInsets.zero, + ), + ), + ], + ), + ), + ), + SizedBox(width: isTight ? 8 : 12), + if (_showRxTxIndicators) + GestureDetector( + onTap: () => _openLiveTraffic(provider), + onLongPress: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + PacketLogScreen(bleService: provider.bleService), + ), + ); + }, + child: _buildCompactActivityIndicator( + rxActive: provider.rxActivity, + txActive: provider.txActivity, + ), + ) + else + const SizedBox(width: 24), + ], + ); + }, + ); + }, + ); + } + + /// Build tab icon with badge showing count + Widget _buildTabIconWithBadge(IconData icon, int count) { + if (count == 0) { + return Icon(icon); + } + + return Stack( + clipBehavior: Clip.none, + children: [ + Icon(icon), + Positioned( + right: -8, + top: -4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + child: Text( + count > 99 ? '99+' : count.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/live_traffic_screen.dart b/lib/screens/live_traffic_screen.dart new file mode 100644 index 0000000..ee2ce1b --- /dev/null +++ b/lib/screens/live_traffic_screen.dart @@ -0,0 +1,1220 @@ +import 'dart:async'; +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 'packet_log_screen.dart'; +import '../l10n/app_localizations.dart'; + +T? _maybeProvider(BuildContext context) { + try { + return Provider.of(context, listen: false); + } catch (_) { + return null; + } +} + +class LiveTrafficScreen extends StatefulWidget { + final List Function() logReader; + final Listenable? refreshListenable; + final DateTime Function() now; + final VoidCallback? openPacketLogs; + + const LiveTrafficScreen({ + super.key, + required this.logReader, + this.refreshListenable, + DateTime Function()? now, + this.openPacketLogs, + }) : now = now ?? DateTime.now; + + factory LiveTrafficScreen.fromProvider( + ConnectionProvider provider, { + Key? key, + VoidCallback? openPacketLogs, + }) { + return LiveTrafficScreen( + key: key, + logReader: () => provider.bleService.packetLogs, + refreshListenable: provider, + openPacketLogs: openPacketLogs, + ); + } + + @override + State createState() => _LiveTrafficScreenState(); +} + +class _LiveTrafficScreenState extends State { + static const List _windowOptions = [ + Duration(minutes: 1), + Duration(minutes: 5), + Duration(minutes: 10), + Duration(minutes: 15), + Duration(minutes: 30), + Duration(minutes: 60), + ]; + + Timer? _ticker; + DateTime? _clearedAt; + int _preferredHashSize = RouteHashPreferences.defaultHashSize; + Duration _selectedWindow = const Duration(minutes: 1); + String? _selectedPacketType; + + @override + void initState() { + super.initState(); + _loadPreferredHashSize(); + widget.refreshListenable?.addListener(_handleRefresh); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) { + setState(() {}); + } + }); + } + + @override + void didUpdateWidget(covariant LiveTrafficScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.refreshListenable != widget.refreshListenable) { + oldWidget.refreshListenable?.removeListener(_handleRefresh); + widget.refreshListenable?.addListener(_handleRefresh); + } + } + + @override + void dispose() { + widget.refreshListenable?.removeListener(_handleRefresh); + _ticker?.cancel(); + super.dispose(); + } + + void _handleRefresh() { + if (!mounted) return; + setState(() {}); + } + + Future _loadPreferredHashSize() async { + final hashSize = await RouteHashPreferences.getHashSize(); + if (!mounted) return; + setState(() { + _preferredHashSize = hashSize; + }); + } + + @override + Widget build(BuildContext context) { + final logs = widget.logReader(); + final totalRxDataCount = LiveTrafficSummary.countRxDataLogs(logs); + final unfilteredSnapshot = LiveTrafficSummary.fromLogs( + logs, + now: widget.now(), + clearedAt: _clearedAt, + preferredHashSize: _preferredHashSize, + window: _selectedWindow, + ); + final snapshot = LiveTrafficSummary.fromLogs( + logs, + now: widget.now(), + clearedAt: _clearedAt, + preferredHashSize: _preferredHashSize, + window: _selectedWindow, + packetTypeFilter: _selectedPacketType, + ); + final theme = Theme.of(context); + final contactsProvider = _maybeProvider(context); + final routeHashCounts = _RouteHashCounts.fromContacts( + contactsProvider?.contacts ?? const [], + ); + final packetTypes = + unfilteredSnapshot.visibleEntries + .map((entry) => entry.payloadLabel) + .toSet() + .toList() + ..sort(); + final filteredEntries = snapshot.visibleEntries; + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.liveTraffic), + actions: [ + if (widget.openPacketLogs != null) + IconButton( + onPressed: widget.openPacketLogs, + 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: AppLocalizations.of(context)!.clearLiveView, + icon: const Icon(Icons.cleaning_services_outlined), + ), + ], + ), + body: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SummaryPanel( + snapshot: snapshot, + totalRxCount: totalRxDataCount, + routeHashCounts: routeHashCounts, + onWindowTap: () => _showWindowPicker(context), + ), + const SizedBox(height: 10), + _PacketTypeFilterBar( + packetTypes: packetTypes, + selectedType: _selectedPacketType, + onSelected: (value) { + setState(() { + _selectedPacketType = value; + }); + }, + ), + ], + ), + ), + ), + if (filteredEntries.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.radar_rounded, + size: 64, + color: theme.colorScheme.primary.withValues( + alpha: 0.45, + ), + ), + const SizedBox(height: 16), + const Text( + 'No packets for this filter', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + unfilteredSnapshot.visibleEntries.isEmpty + ? 'This view only shows in-memory traffic while it is active.' + : 'Try a different packet type or switch back to All.', + textAlign: TextAlign.center, + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + sliver: SliverList.separated( + itemBuilder: (context, index) { + final entry = filteredEntries[index]; + return _LiveTrafficCard(entry: entry, now: widget.now()); + }, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemCount: filteredEntries.length, + ), + ), + ], + ), + ); + } + + Future _openStatsDashboard() async { + final url = TrafficStatsReportingService.dashboardUri; + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + } + + Future _showWindowPicker(BuildContext context) async { + final selected = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (final option in _windowOptions) + ListTile( + title: Text(_windowLabel(option)), + trailing: option == _selectedWindow + ? const Icon(Icons.check) + : null, + onTap: () => Navigator.of(context).pop(option), + ), + ], + ), + ); + }, + ); + + if (selected == null || !mounted) return; + setState(() { + _selectedWindow = selected; + }); + } + + Future _showPacketTypeHelpSheet(BuildContext context) { + final packetTypes = LiveTrafficEntry.knownPayloadTypes; + return showModalBottomSheet( + 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 { + final LiveTrafficSnapshot snapshot; + final int? totalRxCount; + final _RouteHashCounts routeHashCounts; + final VoidCallback onWindowTap; + + const _SummaryPanel({ + required this.snapshot, + required this.totalRxCount, + required this.routeHashCounts, + required this.onWindowTap, + }); + + @override + Widget build(BuildContext context) { + final busynessColor = switch (snapshot.busyness) { + LiveTrafficBusyness.quiet => Colors.blueGrey, + LiveTrafficBusyness.active => Colors.orange, + LiveTrafficBusyness.busy => Colors.redAccent, + }; + final busynessLabel = switch (snapshot.busyness) { + LiveTrafficBusyness.quiet => 'Quiet', + 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( + colors: [ + busynessColor.withValues(alpha: 0.18), + Theme.of(context).colorScheme.surfaceContainerHigh, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _SummaryBadge( + label: AppLocalizations.of(context)!.mesh, + value: busynessLabel, + color: busynessColor, + ), + _SummaryBadge( + label: AppLocalizations.of(context)!.rate, + value: '${snapshot.packetsPerMinute} pkt/min', + color: Theme.of(context).colorScheme.primary, + ), + _SummaryBadge( + label: AppLocalizations.of(context)!.window, + value: _windowLabel(snapshot.windowDuration), + color: Theme.of(context).colorScheme.secondary, + onTap: onWindowTap, + ), + ], + ), + const SizedBox(height: 14), + 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), + ], + ); + }, + ), + ], + ), + ); + } +} + +class _SummaryBadge extends StatelessWidget { + final String label; + final String value; + final Color color; + final VoidCallback? onTap; + + const _SummaryBadge({ + required this.label, + required this.value, + required this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$label ', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 12), + ), + ], + ), + ), + ); + } +} + +class _PacketTypeFilterBar extends StatelessWidget { + final List packetTypes; + final String? selectedType; + final ValueChanged onSelected; + + const _PacketTypeFilterBar({ + required this.packetTypes, + required this.selectedType, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: AppLocalizations.of(context)!.all, + selected: selectedType == null, + onTap: () => onSelected(null), + ), + for (final type in packetTypes) ...[ + const SizedBox(width: 8), + _FilterChip( + label: type, + selected: selectedType == type, + onTap: () => onSelected(type), + ), + ], + ], + ), + ); + } +} + +class _FilterChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _FilterChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final color = selected ? scheme.primary : scheme.outline; + return InkWell( + onTap: onTap, + 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, + ), + ), + ), + ); + } +} + +String _windowLabel(Duration duration) { + return '${duration.inMinutes} min'; +} + +String _windowSummaryLabel(Duration duration) { + final minutes = duration.inMinutes; + if (minutes == 1) return 'Last 1 min'; + return 'Last $minutes min'; +} + +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, + this.footer, + }); + + @override + Widget build(BuildContext context) { + return Container( + key: tileKey, + height: 136, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + Text( + value, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 4), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (footer != null) ...[ + const SizedBox(height: 2), + Text( + footer!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _RouteHashCounts { + final int oneByte; + final int twoByte; + final int threeByte; + + const _RouteHashCounts({ + required this.oneByte, + required this.twoByte, + required this.threeByte, + }); + + factory _RouteHashCounts.fromContacts(List contacts) { + var oneByte = 0; + var twoByte = 0; + var threeByte = 0; + + for (final contact in contacts) { + if (!contact.routeHasPath || contact.routeHopCount <= 0) continue; + switch (contact.routeHashSize) { + case 1: + oneByte += 1; + break; + case 2: + twoByte += 1; + break; + case 3: + threeByte += 1; + break; + } + } + + return _RouteHashCounts( + oneByte: oneByte, + twoByte: twoByte, + threeByte: threeByte, + ); + } + + String get summaryLabel => '1b:$oneByte 2b:$twoByte 3b:$threeByte'; +} + +class _LiveTrafficCard extends StatelessWidget { + final LiveTrafficEntry entry; + final DateTime now; + + const _LiveTrafficCard({required this.entry, required this.now}); + + @override + Widget build(BuildContext context) { + final log = entry.log; + final isRx = log.direction == PacketDirection.rx; + final accent = isRx ? Colors.green : Colors.blue; + final rxInfo = log.logRxDataInfo; + final originDistance = _originDistanceLabel(context, entry); + final channelsProvider = _maybeProvider(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), + borderRadius: BorderRadius.circular(18), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: accent.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + isRx ? 'RX' : 'TX', + style: TextStyle( + color: accent, + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + packetDetails.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + if (entry.payloadMeaning != null) + Text( + entry.payloadMeaning!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (signalMetric != null) ...[ + const SizedBox(width: 12), + CompactSignalIndicator(metric: signalMetric), + ] else + Text( + _timeAgo(log.timestamp, now), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 10), + _PacketInfoLine( + text: + '${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes', + ), + _PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'), + if (packetDetails.pathLine != null) + _PacketInfoLine(text: packetDetails.pathLine!), + if (packetDetails.pathHashLine != null) + _PacketInfoLine(text: packetDetails.pathHashLine!), + if (packetDetails.endpointLine != null) + _PacketInfoLine(text: packetDetails.endpointLine!), + if (originDistance != null) + _PacketInfoLine(text: 'Origin: $originDistance'), + ], + ), + ), + ), + ); + } + + static String _timeAgo(DateTime timestamp, DateTime now) { + final diff = now.difference(timestamp); + if (diff.inSeconds < 60) return '${diff.inSeconds}s ago'; + return '${diff.inMinutes}m ago'; + } + + static String _formatClock(DateTime timestamp) { + final hour = timestamp.hour.toString().padLeft(2, '0'); + final minute = timestamp.minute.toString().padLeft(2, '0'); + final second = timestamp.second.toString().padLeft(2, '0'); + return '$hour:$minute:$second'; + } + + static String? _originDistanceLabel( + BuildContext context, + LiveTrafficEntry entry, + ) { + final route = entry.route; + if (route == null || route.hopHashes.isEmpty) { + return null; + } + + final contactsProvider = _maybeProvider(context); + final connectionProvider = _maybeProvider(context); + final ownLatLng = _ownLatLng(connectionProvider); + final resolved = LogRxRouteDecoder.resolveHash( + route.hopHashes.first, + contacts: contactsProvider?.contacts ?? const [], + ownPublicKey: connectionProvider?.deviceInfo.publicKey, + ownName: + connectionProvider?.deviceInfo.selfName ?? + connectionProvider?.deviceInfo.displayName, + ownLatitude: ownLatLng?.latitude, + ownLongitude: ownLatLng?.longitude, + ); + if (resolved.latitude == null || resolved.longitude == null) { + return null; + } + + final currentPosition = LocationTrackingService().currentPosition; + final originDistanceMeters = currentPosition != null + ? Geolocator.distanceBetween( + currentPosition.latitude, + currentPosition.longitude, + resolved.latitude!, + resolved.longitude!, + ) + : ownLatLng != null + ? Geolocator.distanceBetween( + ownLatLng.latitude, + ownLatLng.longitude, + resolved.latitude!, + resolved.longitude!, + ) + : null; + if (originDistanceMeters == null) { + return null; + } + return _formatDistance(originDistanceMeters); + } + + static _GeoPoint? _ownLatLng(ConnectionProvider? connectionProvider) { + final advLat = connectionProvider?.deviceInfo.advLat; + final advLon = connectionProvider?.deviceInfo.advLon; + if (advLat == null || advLon == null || (advLat == 0 && advLon == 0)) { + return null; + } + return _GeoPoint(advLat / 1e6, advLon / 1e6); + } + + static String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + + static Future _showPacketBytesSheet( + BuildContext context, + List bytes, + ) { + final data = bytes.toList(growable: false); + final hexLines = []; + const bytesPerLine = 16; + for (var offset = 0; offset < data.length; offset += bytesPerLine) { + final chunk = data.skip(offset).take(bytesPerLine).toList(); + final hex = chunk + .map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(' '); + hexLines.add( + '${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex', + ); + } + + final ascii = data + .map( + (byte) => + (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.', + ) + .join(); + + return showModalBottomSheet( + 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: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Packet Bytes', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + '${data.length} bytes', + style: TextStyle(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + Text(AppLocalizations.of(context)!.hex, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: SelectableText( + hexLines.join('\n'), + style: TextStyle( + fontSize: 12, + height: 1.45, + fontFamily: 'monospace', + color: scheme.onSurface, + ), + ), + ), + const SizedBox(height: 16), + Text(AppLocalizations.of(context)!.ascii, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: SelectableText( + ascii.isEmpty ? '(empty)' : ascii, + style: TextStyle( + fontSize: 12, + height: 1.45, + fontFamily: 'monospace', + color: scheme.onSurface, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + +} + +class _LiveTrafficPacketDetails { + final String title; + final String packetHashHex; + final String? pathLine; + final String? pathHashLine; + final String? endpointLine; + + const _LiveTrafficPacketDetails({ + required this.title, + required this.packetHashHex, + required this.pathLine, + required this.pathHashLine, + required this.endpointLine, + }); + + 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) { + 0x05 => parsedPayload?.channelDisplayName ?? LiveTrafficEntry.payloadTypeTitle(0x05), + 0x06 => parsedPayload?.channelDisplayName ?? LiveTrafficEntry.payloadTypeTitle(0x06), + null => entry.payloadLabel.toUpperCase(), + _ => LiveTrafficEntry.payloadTypeTitle(payloadType), + }; + + final hopHashes = route?.hopHashes ?? const []; + final pathLine = hopHashes.isEmpty + ? null + : 'Path: ${hopHashes.length} hop${hopHashes.length == 1 ? '' : 's'} [${hopHashes.join(',')}]'; + final pathHashLine = route == null + ? null + : 'Path Hashes: ${route.hashSize}-byte per hop'; + + return _LiveTrafficPacketDetails( + title: title, + packetHashHex: _packetHash(entry.log.rawData), + pathLine: pathLine, + pathHashLine: pathHashLine, + endpointLine: parsedPayload?.endpointLine, + ); + } + + static String _packetHash(List bytes) { + final digest = md5.convert(bytes).bytes; + return digest + .take(8) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + } +} + +class _ParsedTrafficPayload { + final String? endpointLine; + final String? channelDisplayName; + + const _ParsedTrafficPayload({this.endpointLine, this.channelDisplayName}); + + static _ParsedTrafficPayload? tryParse( + List rawData, + DecodedLogRxRoute? route, { + ChannelsProvider? channelsProvider, + }) { + if (rawData.length < 5 || + rawData.first != LiveTrafficSummary.logRxDataResponseCode) { + return null; + } + + final rawPacketData = rawData.sublist(3); + if (rawPacketData.length < 2) return null; + + final header = rawPacketData[0]; + final routeType = header & 0x03; + final payloadType = (header >> 2) & 0x0F; + + var index = 1; + if (routeType == 0x00 || routeType == 0x03) { + if (rawPacketData.length < index + 5) return null; + index += 4; + } + + if (rawPacketData.length <= index) return null; + final pathDescriptor = rawPacketData[index++]; + final pathByteLen = + LogRxRouteDecoder.descriptorByteLength(pathDescriptor) ?? + (pathDescriptor == 0xFF ? 0 : null); + if (pathByteLen == null || rawPacketData.length < index + pathByteLen) { + return null; + } + index += pathByteLen; + final payload = rawPacketData.sublist(index); + final senderHash = route?.hopHashes.isNotEmpty == true + ? route!.hopHashes.first + : null; + + switch (payloadType) { + 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( + channelDisplayName: channelDisplayName, + endpointLine: channelDisplayName == null + ? 'Channel Hash: $channelHashHex' + : 'Channel: $channelDisplayName ($channelHashHex)', + ); + case 0x00: + case 0x01: + case 0x07: + if (payload.isEmpty) return const _ParsedTrafficPayload(); + final destinationHash = payload.first + .toRadixString(16) + .padLeft(2, '0') + .toUpperCase(); + return _ParsedTrafficPayload( + endpointLine: senderHash == null + ? 'To: <$destinationHash>' + : 'From: <${senderHash.toUpperCase()}> To: <$destinationHash>', + ); + default: + return const _ParsedTrafficPayload(); + } + } +} + +class _PacketInfoLine extends StatelessWidget { + final String text; + + const _PacketInfoLine({required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + text, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ); + } +} + +class _GeoPoint { + final double latitude; + final double longitude; + + const _GeoPoint(this.latitude, this.longitude); +} + +void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => LiveTrafficScreen.fromProvider( + provider, + openPacketLogs: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PacketLogScreen(bleService: provider.bleService), + ), + ); + }, + ), + ), + ); +} diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart new file mode 100644 index 0000000..c937ee6 --- /dev/null +++ b/lib/screens/map_tab.dart @@ -0,0 +1,4202 @@ +import 'dart:async'; +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'; +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'; +import '../providers/map_provider.dart'; +import '../providers/drawing_provider.dart'; +import '../providers/app_provider.dart'; +import '../providers/connection_provider.dart'; +import '../providers/offline_tiles_provider.dart' as offline; +import '../models/contact.dart'; +import '../models/custom_map_config.dart'; +import '../models/map_coordinate_space.dart'; +import '../models/sar_marker.dart'; +import '../models/map_layer.dart'; +import '../models/message.dart'; +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/profiles_feature_service.dart'; +import '../widgets/map_debug_info.dart'; +import '../widgets/map/compass_widget.dart'; +import '../widgets/map/detailed_compass_dialog.dart'; +import '../widgets/map/drawing_layer.dart'; +import '../widgets/map/drawing_toolbar.dart'; +import '../widgets/map/location_trail_layer.dart'; +import '../widgets/map/polygon_draw_handler.dart' hide DrawingToolbar; +import '../widgets/map/trail_controls.dart'; +import '../widgets/map/map_message_overlay.dart'; +import '../widgets/messages/custom_map_sar_update_sheet.dart'; +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'; + +class MapTab extends StatefulWidget { + final Function(bool)? onFullscreenChanged; + final VoidCallback? onNavigateToMessages; + + const MapTab({ + super.key, + this.onFullscreenChanged, + this.onNavigateToMessages, + }); + + @override + State createState() => _MapTabState(); +} + +class _MapTabState extends State with AutomaticKeepAliveClientMixin { + final MapController _mapController = MapController(); + static final MapCachingProvider _tileCachingProvider = + OfflineMapCachingProvider( + BuiltInMapCachingProvider.getOrCreateInstance( + maxCacheSize: 10_000_000_000, + overrideFreshAge: const Duration(days: 365), + ), + ); + static final TileProvider _tileProvider = NetworkTileProvider( + cachingProvider: _tileCachingProvider, + ); + // Providers for layers that need extra HTTP headers (e.g. Mapy.cz Referer), + // cached per URL template so they are not recreated on every build. + static final Map _headeredTileProviders = {}; + + static TileProvider _tileProviderFor(MapLayer layer) { + final headers = layer.headers; + if (headers == null) return _tileProvider; + return _headeredTileProviders.putIfAbsent( + layer.urlTemplate, + () => NetworkTileProvider( + headers: headers, + cachingProvider: _tileCachingProvider, + ), + ); + } + // DO NOT create a new LocationTrackingService instance here + // Use the singleton from AppProvider instead via _locationService getter + final MapMarkerService _markerService = MapMarkerService(); + bool _isMapReady = false; // Track when map widget is actually rendered + MapLayer _currentLayer = MapLayer.openStreetMap; + double? _compassHeading; // Compass sensor heading + bool _rotateMarkerWithHeading = false; // Toggle for rotation + bool _showMapDebugInfo = false; // Toggle for debug info + bool _isFullscreen = false; // Toggle for fullscreen mode + double _gpsUpdateDistance = 3.0; // meters + bool _backgroundTrackingEnabled = false; // Toggle for background tracking + StreamSubscription? _compassStreamSubscription; + final BackgroundLocationService _backgroundLocationService = + BackgroundLocationService(); + bool _isDisposing = false; // Flag to prevent updates during disposal + MapProvider? _mapProvider; + offline.OfflineTilesProvider? _offlineTilesProvider; + + // Store original location callback to restore in dispose + void Function(Position)? _originalLocationCallback; + + // WMS layers (Slovenian) + late final MapLayer _slovenianAerialLayer; + late final MapLayer _dtk25Layer; + + // Dropped pin state + LatLng? _droppedPinLocation; + bool _isDraggingPin = false; + final GlobalKey _pinMarkerKey = GlobalKey(); + + // Saved map position (loaded from SharedPreferences) + LatLng? _savedMapCenter; + double? _savedMapZoom; + bool _isCalibratingCustomMap = false; + LatLng? _customMapCalibrationPointA; + LatLng? _customMapCalibrationPointB; + String _lastCustomMapViewportKey = ''; + + // Default center point (will be updated based on markers) + static const LatLng _defaultCenter = LatLng( + 46.0569, + 14.5058, + ); // Ljubljana, Slovenia + static const double _defaultZoom = 13.0; + static const double _customMapMinZoom = -4.0; + static const double _customMapMaxZoom = 40.0; + + @override + bool get wantKeepAlive => true; + + // Access the singleton LocationTrackingService from AppProvider + LocationTrackingService get _locationService => LocationTrackingService(); + + @override + void initState() { + super.initState(); + // Initialize Slovenian WMS layers with CRS + _slovenianAerialLayer = MapLayer.getSlovenianAerial2024(slovenianCrs); + _dtk25Layer = MapLayer.getDTK25(slovenianCrs); + _loadSettings(); + _markMapReadyWhenMounted(); + _setupLocationCallbacks(); + _startCompassTracking(); + + // Listen to map provider for navigation requests + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; // Check if widget is still mounted + final mapProvider = context.read(); + _mapProvider = mapProvider; + mapProvider.addListener(_handleMapNavigation); + // Load WMS overlay state + mapProvider.loadOverlayState(); + + // Initialize background location service with BLE service + final appProvider = context.read(); + _backgroundLocationService.initialize( + appProvider.connectionProvider.bleService, + ); + + // Restore background tracking state + _restoreBackgroundTracking(); + + final offlineProvider = context.read(); + _offlineTilesProvider = offlineProvider; + offlineProvider.refreshCacheSize(); + offlineProvider.refreshLocalStyles(); + offlineProvider.startPeerDiscovery(); + }); + } + + /// Setup location tracking callbacks for map-specific features + /// Note: LocationTrackingService is initialized and started by AppProvider + /// This method only adds map-specific callbacks for rotation and UI updates + void _setupLocationCallbacks() { + // Store the original callback from AppProvider to restore in dispose + _originalLocationCallback = _locationService.onPositionUpdate; + + // Add map-specific callback that chains with the original + _locationService.onPositionUpdate = (position) { + // Call original callback first (AppProvider's logging) + _originalLocationCallback?.call(position); + + // Then handle map-specific logic - early exit if not mounted or disposing + if (!mounted || _isDisposing) { + return; + } + + setState(() { + // Position updates trigger UI rebuild for markers + }); + + // Add location point to trail when tracking is active + if (_locationService.isTracking) { + try { + final mapProvider = context.read(); + mapProvider.addTrailPoint( + LatLng(position.latitude, position.longitude), + accuracy: position.accuracy, + speed: position.speed, + ); + } catch (e) { + // Context might be invalid during disposal, ignore + debugPrint('Failed to add trail point: $e'); + } + } + + // Rotate map if rotation mode is enabled and heading is available + if (_isMapReady && _rotateMarkerWithHeading && position.heading >= 0) { + try { + final camera = _mapController.camera; + _mapController.moveAndRotate( + camera.center, + camera.zoom, + -position.heading, + ); + } catch (e) { + // Map not ready yet or controller disposed, ignore + } + } + }; + } + + 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; + + 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 + } + } + } 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 _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + // Load last map position if available + final lastLat = prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_latitude'), + ); + final lastLon = prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_longitude'), + ); + final lastZoom = prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_zoom'), + ); + + // Load last map layer + final lastLayerType = prefs.getInt( + ProfileStorageScope.scopedKey('map_last_layer_type'), + ); + setState(() { + _rotateMarkerWithHeading = + prefs.getBool( + ProfileStorageScope.scopedKey('map_rotate_with_heading'), + ) ?? + false; + _showMapDebugInfo = + prefs.getBool( + ProfileStorageScope.scopedKey('map_show_debug_info'), + ) ?? + false; + _isFullscreen = + prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ?? + false; + + // Notify parent about initial fullscreen state + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onFullscreenChanged?.call(_isFullscreen); + }); + _gpsUpdateDistance = + prefs.getDouble( + ProfileStorageScope.scopedKey('map_gps_update_distance'), + ) ?? + 3.0; + _backgroundTrackingEnabled = + prefs.getBool( + ProfileStorageScope.scopedKey('background_tracking_enabled'), + ) ?? + false; + + // Store saved position for use in build + if (lastLat != null && lastLon != null && lastZoom != null) { + _savedMapCenter = LatLng(lastLat, lastLon); + _savedMapZoom = lastZoom; + } + + // Restore last used map layer. + if (lastLayerType != null) { + final layerType = MapLayerType.values[lastLayerType]; + if (layerType == MapLayerType.vectorMbtiles) { + _currentLayer = MapLayer.openStreetMap; + } else if (layerType == MapLayerType.wmsBase) { + // Use Slovenian aerial layer if that's what was saved + _currentLayer = _slovenianAerialLayer; + } else { + // Use default layer + _currentLayer = MapLayer.allLayers.firstWhere( + (layer) => layer.type == layerType, + orElse: () => MapLayer.openStreetMap, + ); + } + } + + // Clamp saved zoom if it exceeds the current layer's maximum + // For WMS layers, use a middle zoom (11) instead of max zoom to avoid extreme close-up + if (_savedMapZoom != null && _savedMapZoom! > _currentLayer.maxZoom) { + _savedMapZoom = _currentLayer.isWms ? 11.0 : _currentLayer.maxZoom; + } + }); + } + } + + Future _saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + ProfileStorageScope.scopedKey('map_rotate_with_heading'), + _rotateMarkerWithHeading, + ); + await prefs.setBool( + ProfileStorageScope.scopedKey('map_show_debug_info'), + _showMapDebugInfo, + ); + await prefs.setBool( + ProfileStorageScope.scopedKey('map_fullscreen'), + _isFullscreen, + ); + await prefs.setDouble( + ProfileStorageScope.scopedKey('map_gps_update_distance'), + _gpsUpdateDistance, + ); + await prefs.setBool( + ProfileStorageScope.scopedKey('background_tracking_enabled'), + _backgroundTrackingEnabled, + ); + + // Save layer type. + await prefs.setInt( + ProfileStorageScope.scopedKey('map_last_layer_type'), + _currentLayer.type.index, + ); + await prefs.remove(ProfileStorageScope.scopedKey('map_last_layer_name')); + } + + Future _saveMapPosition() async { + if (!_isMapReady) return; + if (_mapProvider?.isUsingCustomMap == true) return; + try { + final prefs = await SharedPreferences.getInstance(); + final camera = _mapController.camera; + await prefs.setDouble( + ProfileStorageScope.scopedKey('map_last_latitude'), + camera.center.latitude, + ); + await prefs.setDouble( + ProfileStorageScope.scopedKey('map_last_longitude'), + camera.center.longitude, + ); + await prefs.setDouble( + ProfileStorageScope.scopedKey('map_last_zoom'), + camera.zoom, + ); + } catch (e) { + debugPrint('Error saving map position: $e'); + } + } + + void _handleMapNavigation() { + final mapProvider = context.read(); + if (!_isMapReady) return; + + try { + final customMapConfig = mapProvider.customMapConfig; + + if (mapProvider.targetBounds != null) { + final bounds = + mapProvider.targetCoordinateSpace == MapCoordinateSpace.customMap && + customMapConfig != null + ? _toDisplayBounds(customMapConfig, mapProvider.targetBounds!) + : mapProvider.targetBounds!; + + _mapController.fitCamera( + CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)), + ); + mapProvider.clearNavigation(); + return; + } + + if (mapProvider.targetLocation != null) { + final location = + mapProvider.targetCoordinateSpace == MapCoordinateSpace.customMap && + customMapConfig != null + ? _toDisplayMapPoint(customMapConfig, mapProvider.targetLocation!) + : mapProvider.targetLocation!; + final zoom = + mapProvider.targetCoordinateSpace == MapCoordinateSpace.customMap + ? (mapProvider.targetZoom ?? 2.0) + .clamp(_customMapMinZoom, _customMapMaxZoom) + .toDouble() + : mapProvider.targetZoom ?? _defaultZoom; + + _mapController.move(location, zoom); + mapProvider.clearNavigation(); + } + } catch (e) { + debugPrint('Map controller not ready for navigation: $e'); + } + } + + void _markMapReadyWhenMounted() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + Future.delayed(const Duration(milliseconds: 100), () { + if (!mounted) return; + setState(() { + _isMapReady = true; + }); + debugPrint('Map is now ready for controller operations'); + }); + }); + } + + @override + void dispose() { + // Set flag immediately to prevent any async callbacks from firing + _isDisposing = true; + + // Cancel compass subscription first to stop new events + _compassStreamSubscription?.cancel(); + _compassStreamSubscription = null; + + // Save map position before disposing + _saveMapPosition(); + + _mapProvider?.removeListener(_handleMapNavigation); + _offlineTilesProvider?.stopPeerDiscovery(); + + // DO NOT stop location tracking - it's managed by AppProvider + // Restore the original callback instead of setting to null + _locationService.onPositionUpdate = _originalLocationCallback; + _mapController.dispose(); + super.dispose(); + } + + // Get the current heading from compass or GPS + double? get _currentHeading { + // Prefer compass heading as it works when stationary + if (_compassHeading != null) { + return _compassHeading; + } + // Fall back to GPS heading when moving + final currentPosition = _locationService.currentPosition; + if (currentPosition?.heading != null && currentPosition!.heading >= 0) { + return currentPosition.heading; + } + return null; + } + + // Safely get map rotation, returns 0.0 if map is not ready + double _getMapRotation() { + if (!_isMapReady) return 0.0; + try { + return _mapController.camera.rotation; + } catch (e) { + // Map controller not ready yet + return 0.0; + } + } + + double _getMapZoom() { + if (!_isMapReady) return _savedMapZoom ?? _defaultZoom; + try { + return _mapController.camera.zoom; + } catch (e) { + return _savedMapZoom ?? _defaultZoom; + } + } + + LatLng _calculateCenter(List contacts, List sarMarkers) { + return _markerService.calculateCenter( + contacts: contacts, + sarMarkers: sarMarkers, + defaultCenter: _defaultCenter, + ); + } + + Future _selectMapLayer(MapProvider mapProvider, MapLayer layer) async { + await mapProvider.exitCustomMapMode(); + if (!mounted) { + return; + } + + setState(() { + _currentLayer = layer; + if (_isMapReady && _mapController.camera.zoom > layer.maxZoom) { + _mapController.move( + _mapController.camera.center, + layer.isWms ? 11.0 : layer.maxZoom, + ); + } + }); + + await _saveSettings(); + } + + void _showLayerSelector(BuildContext context, {int initialTab = 0}) { + final rootContext = this.context; + if (_currentLayer.urlTemplate.isNotEmpty) { + rootContext + .read() + .setSelectedLayerIfDifferent(_currentLayer); + } + rootContext.read().refreshCacheSize(); + rootContext.read().refreshLocalStyles(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => DefaultTabController( + length: 3, + initialIndex: initialTab, + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.82, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const Icon(Icons.layers), + SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of(context)!.selectMapLayer, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + const Divider(), + const TabBar( + tabs: [ + Tab(icon: Icon(Icons.layers), text: 'Layers'), + Tab(icon: Icon(Icons.download), text: 'Download'), + Tab(icon: Icon(Icons.offline_pin), text: 'Cached'), + ], + ), + const Divider(height: 1), + Expanded( + child: TabBarView( + children: [ + ListView( + children: [ + Consumer( + builder: (context, mapProvider, _) { + final customMapConfig = mapProvider.customMapConfig; + if (customMapConfig == null) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Saved maps', + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(color: Colors.grey[600]), + ), + ), + ), + ListTile( + leading: mapProvider.isUsingCustomMap + ? const Icon( + Icons.check_circle, + color: Colors.green, + ) + : const Icon( + Icons.radio_button_unchecked, + ), + title: Text(customMapConfig.displayName), + subtitle: Text( + 'Custom picture map • Map ID ${customMapConfig.mapId}', + ), + onTap: () async { + await mapProvider.enterCustomMapMode(); + if (!context.mounted) { + return; + } + Navigator.pop(context); + }, + ), + const Divider(), + ], + ); + }, + ), + // Online layers section + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + AppLocalizations.of(context)!.onlineLayers, + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: Colors.grey[600]), + ), + ), + ...MapLayer.allLayers.map( + (layer) => ListTile( + leading: + !rootContext + .read() + .isUsingCustomMap && + _currentLayer == layer + ? const Icon( + Icons.check_circle, + color: Colors.green, + ) + : const Icon(Icons.radio_button_unchecked), + title: Text(layer.getLocalizedName(context)), + subtitle: Text(layer.attribution), + onTap: () async { + await _selectMapLayer( + rootContext.read(), + layer, + ); + if (!context.mounted) { + return; + } + Navigator.pop(context); + }, + ), + ), + // Slovenian WMS base layers (only for Slovenian/Croatian regions) + if (AppLocalizations.of(context)!.localeName == 'sl' || + AppLocalizations.of(context)!.localeName == + 'hr') ...[ + ListTile( + leading: + !rootContext + .read() + .isUsingCustomMap && + _currentLayer == _slovenianAerialLayer + ? const Icon( + Icons.check_circle, + color: Colors.green, + ) + : const Icon(Icons.radio_button_unchecked), + title: Text(_slovenianAerialLayer.name), + subtitle: Text(_slovenianAerialLayer.attribution), + onTap: () async { + await _selectMapLayer( + rootContext.read(), + _slovenianAerialLayer, + ); + if (!context.mounted) { + return; + } + Navigator.pop(context); + }, + ), + ListTile( + leading: + !rootContext + .read() + .isUsingCustomMap && + _currentLayer == _dtk25Layer + ? const Icon( + Icons.check_circle, + color: Colors.green, + ) + : Icon(Icons.radio_button_unchecked), + title: Text( + AppLocalizations.of(context)!.topographicMap, + ), + subtitle: Text(_dtk25Layer.attribution), + onTap: () async { + await _selectMapLayer( + rootContext.read(), + _dtk25Layer, + ); + if (!context.mounted) { + return; + } + Navigator.pop(context); + }, + ), + ], + // WMS Overlays section (only for Slovenian/Croatian regions and when WMS base layer is selected) + if ((AppLocalizations.of(context)!.localeName == 'sl' || + AppLocalizations.of(context)!.localeName == + 'hr') && + !rootContext.read().isUsingCustomMap && + _currentLayer.isWms) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + AppLocalizations.of(context)!.wmsOverlays, + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: Colors.grey[600]), + ), + ), + Consumer( + builder: (context, mapProvider, _) { + return Column( + children: [ + CheckboxListTile( + secondary: const Icon( + Icons.grid_on, + color: Colors.blue, + ), + title: Text( + AppLocalizations.of( + context, + )!.cadastralParcels, + ), + subtitle: const Text('© GURS'), + value: mapProvider.showCadastralOverlay, + onChanged: (value) { + mapProvider.toggleCadastralOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon( + Icons.route, + color: Colors.green, + ), + title: Text( + AppLocalizations.of(context)!.forestRoads, + ), + subtitle: const Text('© GURS'), + value: mapProvider.showForestRoadsOverlay, + onChanged: (value) { + mapProvider.toggleForestRoadsOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon( + Icons.hiking, + color: Colors.brown, + ), + title: Text( + AppLocalizations.of( + context, + )!.hikingTrails, + ), + subtitle: const Text('© GURS'), + value: mapProvider.showHikingTrailsOverlay, + onChanged: (value) { + mapProvider.toggleHikingTrailsOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon( + Icons.alt_route, + color: Colors.grey, + ), + title: Text( + AppLocalizations.of(context)!.mainRoads, + ), + subtitle: const Text('© GURS'), + value: mapProvider.showMainRoadsOverlay, + onChanged: (value) { + mapProvider.toggleMainRoadsOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon( + Icons.numbers, + color: Colors.purple, + ), + title: Text( + AppLocalizations.of( + context, + )!.houseNumbers, + ), + subtitle: const Text('© GURS'), + value: mapProvider.showHouseNumbersOverlay, + onChanged: (value) { + mapProvider.toggleHouseNumbersOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon( + Icons.warning_amber, + color: Colors.orange, + ), + title: Text( + AppLocalizations.of( + context, + )!.fireHazardZones, + ), + subtitle: const Text('© GURS'), + value: + mapProvider.showFireHazardZonesOverlay, + onChanged: (value) { + mapProvider + .toggleFireHazardZonesOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon( + Icons.local_fire_department, + color: Colors.red, + ), + title: Text( + AppLocalizations.of( + context, + )!.historicalFires, + ), + subtitle: const Text('© GURS'), + value: + mapProvider.showHistoricalFiresOverlay, + onChanged: (value) { + mapProvider + .toggleHistoricalFiresOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon( + Icons.forest, + color: Colors.teal, + ), + title: Text( + AppLocalizations.of(context)!.firebreaks, + ), + subtitle: const Text('© GURS'), + value: mapProvider.showFirebreaksOverlay, + onChanged: (value) { + mapProvider.toggleFirebreaksOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon( + Icons.warning, + color: Colors.deepOrange, + ), + title: Text( + AppLocalizations.of( + context, + )!.krasFireZones, + ), + subtitle: const Text('© GURS'), + value: mapProvider.showKrasFireZonesOverlay, + onChanged: (value) { + mapProvider.toggleKrasFireZonesOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon( + Icons.place, + color: Colors.indigo, + ), + title: Text( + AppLocalizations.of(context)!.placeNames, + ), + subtitle: const Text('© GURS'), + value: mapProvider.showPlaceNamesOverlay, + onChanged: (value) { + mapProvider.togglePlaceNamesOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon( + Icons.border_outer, + color: Colors.cyan, + ), + title: Text( + AppLocalizations.of( + context, + )!.municipalityBorders, + ), + subtitle: const Text('© GURS'), + value: mapProvider + .showMunicipalityBordersOverlay, + onChanged: (value) { + mapProvider + .toggleMunicipalityBordersOverlay(); + }, + ), + ], + ); + }, + ), + ], + const Divider(), + Consumer( + builder: (context, mapProvider, _) { + final customMapConfig = mapProvider.customMapConfig; + final hasCustomMap = customMapConfig != null; + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const Icon(Icons.photo_library_outlined), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Custom picture map', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + if (!hasCustomMap) + ListTile( + leading: Icon(Icons.add_photo_alternate), + title: Text( + AppLocalizations.of( + context, + )!.loadFromGallery, + ), + subtitle: const Text( + 'Use a cave map image instead of GPS tiles', + ), + onTap: () async { + final loaded = await mapProvider + .loadCustomMapFromGallery(); + if (!context.mounted || + !rootContext.mounted) { + return; + } + Navigator.pop(context); + if (!loaded) { + return; + } + final drawingProvider = rootContext + .read(); + _syncDrawingContext( + drawingProvider, + mapProvider, + ); + }, + ) + else ...[ + ListTile( + leading: const Icon(Icons.image_outlined), + title: Text(customMapConfig.displayName), + subtitle: Text( + 'Map ID ${customMapConfig.mapId}${customMapConfig.isCalibrated ? ' • Scale set' : ' • Not calibrated'}', + ), + ), + ListTile( + leading: Icon(Icons.swap_horizontal_circle), + title: Text( + AppLocalizations.of( + context, + )!.replaceImage, + ), + subtitle: const Text( + 'Pick a different map from the gallery', + ), + onTap: () async { + await mapProvider.replaceCustomMap(); + if (!context.mounted) return; + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.straighten), + title: Text( + customMapConfig.isCalibrated + ? 'Update scale' + : 'Set scale', + ), + subtitle: const Text( + 'Tap two points on the image and enter meters', + ), + onTap: () async { + if (!mapProvider.isUsingCustomMap) { + await mapProvider.enterCustomMapMode(); + } + if (!context.mounted || + !rootContext.mounted) { + return; + } + Navigator.pop(context); + _startCustomMapCalibration( + rootContext.read(), + ); + }, + ), + if (customMapConfig.isCalibrated) + ListTile( + leading: Icon(Icons.clear), + title: Text( + AppLocalizations.of( + context, + )!.clearScale, + ), + onTap: () async { + await mapProvider + .clearCustomMapCalibration(); + if (!context.mounted) return; + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon( + Icons.delete_outline, + color: Colors.red, + ), + title: Text( + 'Remove custom map', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + ), + ), + subtitle: const Text( + 'Deletes the saved image from this device', + ), + onTap: () async { + await mapProvider.removeCustomMap(); + if (!context.mounted) return; + Navigator.pop(context); + }, + ), + ], + ], + ); + }, + ), + ], + ), + _buildMapDownloadTab(rootContext), + _buildMapCachedTab(rootContext), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildMapDownloadTab(BuildContext rootContext) { + return Consumer( + builder: (context, provider, _) { + final loc = AppLocalizations.of(context)!; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (!provider.isDownloading) ...[ + DropdownButtonFormField( + initialValue: provider.selectedLayer, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.layers, size: 20), + labelText: loc.mapStyle, + border: const OutlineInputBorder(), + ), + isExpanded: true, + items: MapLayer.allLayers + .map( + (layer) => DropdownMenuItem( + value: layer, + child: Text(layer.getLocalizedName(context)), + ), + ) + .toList(), + onChanged: (layer) { + if (layer != null) provider.setSelectedLayer(layer); + }, + ), + const SizedBox(height: 12), + if (provider.localStyles.any((s) => s.region != null)) ...[ + DropdownButtonFormField( + decoration: InputDecoration( + prefixIcon: const Icon(Icons.bookmark, size: 20), + labelText: loc.loadASavedRegion, + border: const OutlineInputBorder(), + ), + isExpanded: true, + items: provider.localStyles + .where((style) => style.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) return; + provider.loadPreset(style); + _fitOfflineMapToPolygons(provider); + }, + ), + const SizedBox(height: 12), + ], + SegmentedButton( + segments: const [ + ButtonSegment( + value: offline.DrawingMode.rectangle, + icon: Icon(Icons.crop_square), + label: Text('Rectangle'), + ), + ButtonSegment( + value: offline.DrawingMode.polygon, + icon: Icon(Icons.polyline), + label: Text('Polygon'), + ), + ButtonSegment( + value: offline.DrawingMode.none, + icon: Icon(Icons.pan_tool_alt), + label: Text('Current view'), + ), + ], + selected: {provider.downloadSelectionMode}, + onSelectionChanged: (selection) { + final mode = selection.first; + if (mode == offline.DrawingMode.none) { + _setOfflineSelectionToCurrentView(provider); + return; + } + provider.startDownloadSelectionMode(mode); + }, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => _setOfflineSelectionToCurrentView(provider), + icon: const Icon(Icons.crop_free), + label: const Text('Use current view'), + ), + const SizedBox(height: 12), + if (provider.drawingMode == offline.DrawingMode.polygon) + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: provider.currentVertices.length >= 3 + ? provider.finishPolygon + : null, + icon: const Icon(Icons.check), + label: const Text('Finish polygon'), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: provider.currentVertices.isNotEmpty + ? provider.undoLastVertex + : null, + icon: const Icon(Icons.undo), + tooltip: loc.undo, + ), + ], + ), + if (provider.hasPolygons) ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: provider.clearPolygons, + icon: const Icon(Icons.delete_outline), + label: const Text('Clear selected area'), + ), + ], + const SizedBox(height: 12), + _MapZoomSelector( + label: loc.minZoom, + value: provider.minZoom, + onChanged: provider.setMinZoom, + ), + _MapZoomSelector( + label: loc.maxZoom, + value: provider.maxZoom, + onChanged: provider.setMaxZoom, + ), + const SizedBox(height: 8), + Text( + provider.hasPolygons + ? '~${_formatNumber(provider.estimatedTileCount)} tiles' + : 'Select an area on the map to download', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + 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, + ), + ], + 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, + ), + ], + if (!provider.isDownloading && provider.progress.isComplete) ...[ + const SizedBox(height: 8), + 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: 16), + if (provider.isDownloading) + OutlinedButton.icon( + onPressed: provider.cancelDownload, + icon: const Icon(Icons.cancel), + label: Text(loc.cancel), + style: OutlinedButton.styleFrom(foregroundColor: Colors.red), + ) + else + FilledButton.icon( + onPressed: provider.hasPolygons + ? () { + provider.startDownload(); + } + : null, + icon: const Icon(Icons.download), + label: Text(loc.download), + ), + ], + ); + }, + ); + } + + Widget _buildMapCachedTab(BuildContext rootContext) { + return Consumer( + builder: (context, provider, _) { + final loc = AppLocalizations.of(context)!; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + SwitchListTile( + title: Text(loc.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(), + ), + if (provider.localStyles.isNotEmpty) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Cached maps', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ...provider.localStyles.map((style) { + final isShowing = provider.coverageStyle?.hash == style.hash; + return ListTile( + 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, + color: isShowing ? Colors.blue : null, + ), + tooltip: isShowing ? 'Hide on map' : 'Show on map', + onPressed: () => provider.showCoverage(style), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: loc.delete, + onPressed: () => _confirmDeleteOfflineStyle( + context, + provider, + style, + ), + ), + ], + ), + ); + }), + if (provider.cacheSizeBytes > 0) + OutlinedButton.icon( + onPressed: () => _confirmClearOfflineCache(context, provider), + icon: const Icon(Icons.delete_forever), + label: Text(loc.clearCache), + ), + ], + const Divider(), + 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: loc.refresh, + onPressed: provider.refreshPeerCatalogs, + ), + IconButton( + icon: const Icon(Icons.add, size: 20), + tooltip: loc.addPeerManually, + onPressed: () => _showAddOfflinePeerDialog(context, provider), + ), + ], + ), + if (provider.discoveredPeers.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No peers found on the local network.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + ...provider.peerCatalogs.map( + (catalog) => _buildOfflinePeerCard(context, provider, catalog), + ), + ...provider.discoveredPeers + .where( + (peer) => !provider.peerCatalogs.any( + (catalog) => catalog.peer == peer, + ), + ) + .map( + (peer) => ListTile( + leading: const Icon(Icons.devices), + title: Text(peer.ipAddress), + subtitle: Text(loc.fetchingCatalog), + trailing: IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: () => provider.removePeer(peer), + ), + ), + ), + if (provider.isSyncing || provider.syncStatus.isNotEmpty) ...[ + const Divider(), + if (provider.isSyncing) + LinearProgressIndicator( + value: provider.syncProgress > 0 + ? provider.syncProgress + : null, + ), + ListTile( + title: Text( + provider.syncStatus, + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: provider.isSyncing + ? TextButton( + onPressed: provider.cancelSync, + child: Text(loc.cancel), + ) + : null, + ), + ], + ], + ); + }, + ); + } + + Widget _buildOfflinePeerCard( + BuildContext context, + offline.OfflineTilesProvider provider, + offline.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) { + final localMatch = provider.localStyles.where( + (localStyle) => localStyle.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 ${_formatNumber(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), + ); + }), + ], + ), + ), + ); + } + + void _fitOfflineMapToPolygons(offline.OfflineTilesProvider provider) { + if (provider.polygons.isEmpty || !_isMapReady) return; + + var minLat = 90.0, maxLat = -90.0; + var minLng = 180.0, maxLng = -180.0; + for (final poly in provider.polygons) { + for (final point in poly) { + if (point.latitude < minLat) minLat = point.latitude; + if (point.latitude > maxLat) maxLat = point.latitude; + if (point.longitude < minLng) minLng = point.longitude; + if (point.longitude > maxLng) maxLng = point.longitude; + } + } + + _mapController.fitCamera( + CameraFit.bounds( + bounds: LatLngBounds(LatLng(minLat, minLng), LatLng(maxLat, maxLng)), + padding: const EdgeInsets.all(50), + ), + ); + } + + void _setOfflineSelectionToCurrentView( + offline.OfflineTilesProvider provider, + ) { + if (!_isMapReady) return; + + final bounds = _mapController.camera.visibleBounds; + provider.setCurrentViewBounds( + north: bounds.north, + south: bounds.south, + east: bounds.east, + west: bounds.west, + ); + } + + void _confirmOfflineSelection( + BuildContext context, + offline.OfflineTilesProvider provider, + ) { + if (provider.drawingMode == offline.DrawingMode.polygon && + provider.currentVertices.length >= 3) { + provider.finishPolygon(); + } else { + provider.setDrawingMode(offline.DrawingMode.none); + } + + if (provider.hasPolygons) { + _showLayerSelector(context, initialTab: 1); + } + } + + void _showAddOfflinePeerDialog( + BuildContext context, + offline.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 _confirmDeleteOfflineStyle( + BuildContext context, + offline.OfflineTilesProvider provider, + offline.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(dialogContext)!.cancel), + ), + TextButton( + onPressed: () { + provider.deleteStyle(style); + Navigator.pop(dialogContext); + }, + child: Text( + AppLocalizations.of(dialogContext)!.delete, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } + + void _confirmClearOfflineCache( + BuildContext context, + offline.OfflineTilesProvider provider, + ) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(context)!.clearOfflineCache), + content: Text( + 'This will delete ${_formatBytes(provider.cacheSizeBytes)} of cached tiles.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + TextButton( + onPressed: () { + provider.clearCache(); + Navigator.pop(dialogContext); + }, + child: Text( + AppLocalizations.of(dialogContext)!.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'; + } + + void _showDetailedCompass( + BuildContext context, + List contacts, + List sarMarkers, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: DetailedCompassDialog( + initialPosition: _locationService.currentPosition, + initialHeading: _compassHeading, + contacts: contacts, + sarMarkers: sarMarkers, + ), + ), + ); + } + + void _showDetailedCompassWithContact( + BuildContext context, + List contacts, + List sarMarkers, + Contact selectedContact, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: DetailedCompassDialog( + initialPosition: _locationService.currentPosition, + initialHeading: _compassHeading, + contacts: contacts, + sarMarkers: sarMarkers, + preSelectedContact: selectedContact, + ), + ), + ); + } + + /// Restore background tracking state on app start + Future _restoreBackgroundTracking() async { + if (_backgroundTrackingEnabled) { + await _startBackgroundTracking(); + } + } + + /// Start background location tracking + Future _startBackgroundTracking() async { + final success = await _backgroundLocationService.startTracking( + distanceThreshold: _gpsUpdateDistance, + ); + + if (!success) { + if (mounted) { + setState(() { + _backgroundTrackingEnabled = false; + }); + _saveSettings(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.failedToStartBackgroundTracking, + ), + duration: Duration(seconds: 3), + ), + ); + } + } + } + + /// Calculate distance between two points in meters + double _calculateDistanceInMeters( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + return _markerService.calculateDistance( + lat1: lat1, + lon1: lon1, + lat2: lat2, + lon2: lon2, + ); + } + + /// Format distance for display + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.toStringAsFixed(1)} m'; + } else { + return '${(meters / 1000).toStringAsFixed(2)} km'; + } + } + + LatLng _toStoredMapPoint(CustomMapConfig config, LatLng point) { + return config.fromDisplayPoint(point); + } + + LatLng _toDisplayMapPoint(CustomMapConfig config, LatLng point) { + return config.toDisplayPoint(point); + } + + LatLngBounds _toDisplayBounds(CustomMapConfig config, LatLngBounds bounds) { + return LatLngBounds.fromPoints([ + _toDisplayMapPoint(config, LatLng(bounds.north, bounds.west)), + _toDisplayMapPoint(config, LatLng(bounds.north, bounds.east)), + _toDisplayMapPoint(config, LatLng(bounds.south, bounds.west)), + _toDisplayMapPoint(config, LatLng(bounds.south, bounds.east)), + ]); + } + + void _syncDrawingContext( + DrawingProvider drawingProvider, + MapProvider mapProvider, + ) { + final coordinateSpace = mapProvider.isUsingCustomMap + ? MapCoordinateSpace.customMap + : MapCoordinateSpace.geo; + final mapId = mapProvider.isUsingCustomMap + ? mapProvider.customMapConfig?.mapId + : null; + final metersPerPixel = mapProvider.isUsingCustomMap + ? mapProvider.customMapConfig?.metersPerPixel + : null; + + if (drawingProvider.activeCoordinateSpace == coordinateSpace && + drawingProvider.activeMapId == mapId && + drawingProvider.activeMetersPerPixel == metersPerPixel) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + drawingProvider.setMapContext( + coordinateSpace: coordinateSpace, + mapId: mapId, + metersPerPixel: metersPerPixel, + ); + }); + } + + void _ensureCustomMapViewport( + MapProvider mapProvider, + CustomMapConfig? customMapConfig, + ) { + final nextKey = mapProvider.isUsingCustomMap && customMapConfig != null + ? customMapConfig.mapId + : ''; + if (_lastCustomMapViewportKey == nextKey) { + return; + } + _lastCustomMapViewportKey = nextKey; + + if (!_isMapReady || + customMapConfig == null || + !mapProvider.isUsingCustomMap) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_isMapReady || !mapProvider.isUsingCustomMap) { + return; + } + try { + _mapController.fitCamera( + CameraFit.bounds( + bounds: customMapConfig.displayBounds, + padding: const EdgeInsets.all(24), + ), + ); + } catch (e) { + debugPrint('Failed to fit custom map viewport: $e'); + } + }); + } + + void _startCustomMapCalibration(DrawingProvider drawingProvider) { + if (!mounted) return; + drawingProvider.exitDrawingMode(); + setState(() { + _droppedPinLocation = null; + _isDraggingPin = false; + _isCalibratingCustomMap = true; + _customMapCalibrationPointA = null; + _customMapCalibrationPointB = null; + }); + } + + void _stopCustomMapCalibration() { + if (!mounted) return; + setState(() { + _isCalibratingCustomMap = false; + _customMapCalibrationPointA = null; + _customMapCalibrationPointB = null; + }); + } + + Future _handleCustomMapCalibrationTap( + MapProvider mapProvider, + LatLng storedPoint, + ) async { + if (!_isCalibratingCustomMap) { + return; + } + + if (_customMapCalibrationPointA == null) { + setState(() { + _customMapCalibrationPointA = storedPoint; + }); + return; + } + + if (_customMapCalibrationPointB == null) { + setState(() { + _customMapCalibrationPointB = storedPoint; + }); + + final controller = TextEditingController(); + final meters = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.setMapScale), + content: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + autofocus: true, + decoration: const InputDecoration( + labelText: 'Distance in meters', + hintText: '25', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + FilledButton( + onPressed: () { + final value = double.tryParse(controller.text.trim()); + if (value == null || value <= 0) { + return; + } + Navigator.pop(dialogContext, value); + }, + child: Text(AppLocalizations.of(dialogContext)!.save), + ), + ], + ); + }, + ); + + if (meters == null || meters <= 0) { + if (mounted) { + setState(() { + _customMapCalibrationPointB = null; + }); + } + return; + } + + final pointA = _customMapCalibrationPointA!; + final pointB = _customMapCalibrationPointB!; + final dy = pointB.latitude - pointA.latitude; + final dx = pointB.longitude - pointA.longitude; + final pixelDistance = math.sqrt((dx * dx) + (dy * dy)); + if (pixelDistance <= 0) { + return; + } + + await mapProvider.setCustomMapCalibration( + pointA: pointA, + pointB: pointB, + metersPerPixel: meters / pixelDistance, + ); + + _stopCustomMapCalibration(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.customMapScaleSaved), + ), + ); + } + } + + void _showCustomMapSarDialogWithPoint( + CustomMapConfig customMapConfig, + LatLng storedPoint, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => CustomMapSarUpdateSheet( + mapName: customMapConfig.displayName, + mapId: customMapConfig.mapId, + pointLabel: + 'Point: ${storedPoint.latitude.toStringAsFixed(0)}, ${storedPoint.longitude.toStringAsFixed(0)}', + onSend: + ( + emoji, + name, + roomPublicKey, + sendToChannel, + sendToAllContacts, + colorIndex, + ) async { + await _sendCustomMapSarMessage( + emoji, + name, + storedPoint, + customMapConfig.mapId, + roomPublicKey, + sendToChannel, + sendToAllContacts, + colorIndex, + ); + }, + ), + ); + } + + Widget _buildTaggedPointMarker({ + required String label, + required Color color, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + padding: const EdgeInsets.all(6), + child: const Icon(Icons.place, color: Colors.white, size: 18), + ), + ], + ); + } + + Future _showSarMarkerActions( + SarMarker marker, + MessagesProvider messagesProvider, + ContactsProvider contactsProvider, + ) async { + final message = messagesProvider.getMessageById(marker.id); + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + final theme = Theme.of(sheetContext); + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(marker.emoji, style: const TextStyle(fontSize: 24)), + const SizedBox(width: 10), + Expanded( + child: Text( + marker.displayName, + style: theme.textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + marker.coordinateSpace == MapCoordinateSpace.customMap + ? 'Point ${marker.location.latitude.toStringAsFixed(0)}, ${marker.location.longitude.toStringAsFixed(0)}' + : '${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}', + style: theme.textTheme.bodyMedium, + ), + if (marker.coordinateSpace == MapCoordinateSpace.customMap && + marker.mapId != null) ...[ + const SizedBox(height: 4), + Text( + 'Map ID ${marker.mapId}', + style: theme.textTheme.bodySmall, + ), + ], + const SizedBox(height: 4), + Text( + marker.senderName != null + ? '${marker.timeAgo} • ${marker.senderName}' + : marker.timeAgo, + style: theme.textTheme.bodySmall, + ), + if (marker.notes != null && marker.notes!.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(marker.notes!), + ], + const SizedBox(height: 16), + if (message != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.chat_bubble_outline), + title: Text(AppLocalizations.of(context)!.openMessage), + subtitle: Text( + AppLocalizations.of(context)!.jumpToTheRelatedSarMessage, + ), + onTap: () async { + Navigator.pop(sheetContext); + await _openSarMarkerMessage( + message, + messagesProvider, + contactsProvider, + ); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.delete_outline, color: Colors.red), + title: Text( + 'Remove marker', + style: TextStyle(color: theme.colorScheme.error), + ), + subtitle: Text( + message != null + ? 'Removes the marker and linked SAR message on this device only.' + : 'Hides this marker from the map on this device only.', + ), + onTap: () async { + final confirmed = await _confirmSarMarkerRemoval( + hasMessage: message != null, + ); + if (!mounted || + !sheetContext.mounted || + confirmed != true) { + return; + } + Navigator.pop(sheetContext); + await messagesProvider.removeSarMarkerPermanently( + marker.id, + ); + }, + ), + ], + ), + ), + ); + }, + ); + } + + Future _confirmSarMarkerRemoval({required bool hasMessage}) { + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(context)!.removeSarMarker), + content: Text( + hasMessage + ? 'This will remove the marker and its linked chat message on this device only. Other team members will still see them.' + : 'This will hide the marker from the map on this device only. Other team members will still see it.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(dialogContext)!.delete), + ), + ], + ), + ); + } + + Future _confirmAndDeleteDrawing( + DrawingProvider drawingProvider, + String drawingId, + ) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(dialogContext)!.deleteDrawing), + content: Text(AppLocalizations.of(dialogContext)!.deleteDrawingConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(dialogContext)!.delete), + ), + ], + ), + ); + if (!mounted || confirmed != true) return; + drawingProvider.removeDrawing(drawingId); + } + + Future _openSarMarkerMessage( + Message message, + MessagesProvider messagesProvider, + ContactsProvider contactsProvider, + ) async { + if (message.isChannelMessage) { + final channelContact = contactsProvider.channels.where((contact) { + return contact.publicKey.length > 1 && + contact.publicKey[1] == (message.channelIdx ?? 0); + }).firstOrNull; + + messagesProvider.navigateToDestination( + MessageDestinationPreferences.destinationTypeChannel, + recipientPublicKeyHex: channelContact?.publicKeyHex, + ); + } else { + Contact? destinationContact; + + if (message.recipientPublicKey != null) { + destinationContact = contactsProvider.contacts.where((contact) { + return contact.publicKey.length >= + message.recipientPublicKey!.length && + contact.publicKey.matches(message.recipientPublicKey!); + }).firstOrNull; + } else if (message.senderPublicKeyPrefix != null && + message.senderPublicKeyPrefix!.length >= 6) { + destinationContact = contactsProvider.findContactByPrefix( + message.senderPublicKeyPrefix!, + ); + } + + if (destinationContact != null) { + messagesProvider.navigateToDestination( + destinationContact.isRoom + ? MessageDestinationPreferences.destinationTypeRoom + : MessageDestinationPreferences.destinationTypeContact, + recipientPublicKeyHex: destinationContact.publicKeyHex, + ); + } + } + + messagesProvider.navigateToMessage(message.id); + widget.onNavigateToMessages?.call(); + } + + /// Show SAR dialog with pre-populated location from map long press + void _showSarDialogWithLocation(LatLng location) { + // Create a Position object from the LatLng coordinates + final position = Position( + latitude: location.latitude, + longitude: location.longitude, + timestamp: DateTime.now(), + accuracy: 0.0, // Unknown accuracy for map-selected point + altitude: 0.0, + altitudeAccuracy: 0.0, + heading: 0.0, + headingAccuracy: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + ); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => SarUpdateSheet( + prePopulatedPosition: position, + allowLocationUpdate: false, // Don't allow changing to current location + onSend: + ( + emoji, + name, + position, + roomPublicKey, + sendToChannel, + sendToAllContacts, + colorIndex, + ) async { + await _sendSarMessage( + emoji, + name, + position, + roomPublicKey, + sendToChannel, + sendToAllContacts, + colorIndex, + ); + }, + ), + ); + } + + Future _sendSarMessage( + String emoji, + String name, + Position position, + Uint8List? roomPublicKey, + bool sendToChannel, + bool sendToAllContacts, + int colorIndex, + ) async { + final sarMessage = SarMessageParser.createSarMessage( + type: SarMarkerType.fromEmoji(emoji), + location: LatLng(position.latitude, position.longitude), + notes: name, + colorIndex: colorIndex, + ); + + await _sendSarPayload( + sarMessage: sarMessage, + roomPublicKey: roomPublicKey, + sendToChannel: sendToChannel, + sendToAllContacts: sendToAllContacts, + ); + } + + Future _sendCustomMapSarMessage( + String emoji, + String name, + LatLng storedPoint, + String mapId, + Uint8List? roomPublicKey, + bool sendToChannel, + bool sendToAllContacts, + int colorIndex, + ) async { + final sarMessage = SarMessageParser.createCustomMapSarMessage( + emoji: emoji, + mapId: mapId, + point: storedPoint, + notes: name, + colorIndex: colorIndex, + ); + + await _sendSarPayload( + sarMessage: sarMessage, + roomPublicKey: roomPublicKey, + sendToChannel: sendToChannel, + sendToAllContacts: sendToAllContacts, + ); + } + + Future _sendSarPayload({ + required String sarMessage, + required Uint8List? roomPublicKey, + required bool sendToChannel, + required bool sendToAllContacts, + }) async { + final connectionProvider = context.read(); + final messagesProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.deviceNotConnected), + backgroundColor: Colors.red, + ), + ); + return; + } + + if (!sendToChannel && !sendToAllContacts && roomPublicKey == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.pleaseSelectADestinationToSendSarMarker, + ), + backgroundColor: Colors.red, + ), + ); + return; + } + + try { + if (sendToAllContacts) { + final contactsProvider = context.read(); + final chatContacts = contactsProvider.chatContacts; + + if (chatContacts.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.noContactsAvailable), + backgroundColor: Colors.red, + ), + ); + return; + } + + final groupId = '${DateTime.now().millisecondsSinceEpoch}_group'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + final recipients = chatContacts.map((contact) { + return MessageRecipient( + publicKey: contact.publicKey, + displayName: contact.displayName, + deliveryStatus: MessageDeliveryStatus.sending, + sentAt: DateTime.now(), + ); + }).toList(); + + final groupedMessage = Message( + id: groupId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: sarMessage, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + groupId: groupId, + recipients: recipients, + ); + + messagesProvider.addSentMessage(groupedMessage); + + int successCount = 0; + for (final contact in chatContacts) { + final individualMessageId = '${groupId}_${contact.publicKeyShort}'; + messagesProvider.registerGroupedMessageSend( + individualMessageId, + groupId, + contact.publicKey, + ); + + final sentSuccessfully = await connectionProvider.sendTextMessage( + contactPublicKey: contact.publicKey, + text: sarMessage, + messageId: individualMessageId, + contact: contact, + ); + + if (sentSuccessfully) { + successCount++; + } else { + messagesProvider.updateGroupedMessageRecipientStatus( + groupId, + contact.publicKey, + MessageDeliveryStatus.failed, + ); + } + + if (contact != chatContacts.last) { + await Future.delayed(const Duration(seconds: 1)); + } + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.sarMarkerSentToContacts(successCount), + ), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + return; + } + + if (sendToChannel) { + final messageId = + '${DateTime.now().millisecondsSinceEpoch}_channel_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + final sentMessage = Message( + id: messageId, + messageType: MessageType.channel, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: sarMessage, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + channelIdx: 0, + ); + + messagesProvider.addSentMessage(sentMessage); + + await connectionProvider.sendChannelMessage( + channelIdx: 0, + text: sarMessage, + messageId: messageId, + ); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.sarMarkerBroadcastToPublicChannel, + ), + backgroundColor: Colors.orange, + duration: Duration(seconds: 2), + ), + ); + return; + } + + final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + final sentMessage = Message( + id: messageId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: sarMessage, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: roomPublicKey, + ); + + final contactsProvider = context.read(); + final roomContact = contactsProvider.contacts.where((c) { + return c.publicKey.length >= roomPublicKey!.length && + c.publicKey.matches(roomPublicKey); + }).firstOrNull; + + messagesProvider.addSentMessage(sentMessage, contact: roomContact); + + final sentSuccessfully = await connectionProvider.sendTextMessage( + contactPublicKey: roomPublicKey!, + text: sarMessage, + messageId: messageId, + contact: roomContact, + ); + + if (!sentSuccessfully) { + messagesProvider.markMessageFailed(messageId); + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.sarMarkerSentToRoom), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.failedToSendSarMarker(e.toString()), + ), + backgroundColor: Colors.red, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); // Required for AutomaticKeepAliveClientMixin + + return Consumer4< + ContactsProvider, + MessagesProvider, + DrawingProvider, + MapProvider + >( + builder: (context, contactsProvider, messagesProvider, drawingProvider, mapProvider, child) { + final customMapConfig = mapProvider.customMapConfig; + final isCustomMapMode = + mapProvider.isUsingCustomMap && customMapConfig != null; + if (!isCustomMapMode && _isCalibratingCustomMap) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _stopCustomMapCalibration(); + } + }); + } + _syncDrawingContext(drawingProvider, mapProvider); + _ensureCustomMapViewport(mapProvider, customMapConfig); + + final allContactsWithLocation = contactsProvider.contactsWithLocation; + // Simple mode: only favourite chat contacts (team members) on the map. + final scopedContactsWithLocation = + context.watch().isSimpleMode + ? allContactsWithLocation + .where( + (contact) => + contact.isFavourite && + contact.type == ContactType.chat, + ) + .toList() + : allContactsWithLocation; + final contactsWithLocation = mapProvider.hideRepeatersOnMap + ? scopedContactsWithLocation + .where((contact) => !contact.isRepeater) + .toList() + : scopedContactsWithLocation; + // Filter SAR markers based on visibility toggle + final allSarMarkers = messagesProvider.sarMarkers; + final sarMarkers = !drawingProvider.showSarMarkers + ? [] + : isCustomMapMode + ? allSarMarkers + .where( + (marker) => + marker.coordinateSpace == + MapCoordinateSpace.customMap && + marker.mapId == customMapConfig.mapId, + ) + .toList() + : allSarMarkers + .where( + (marker) => + marker.coordinateSpace == MapCoordinateSpace.geo, + ) + .toList(); + final center = isCustomMapMode + ? customMapConfig.displayBounds.center + : _calculateCenter(contactsWithLocation, sarMarkers); + final pointTransformer = isCustomMapMode + ? (LatLng point) => _toDisplayMapPoint(customMapConfig, point) + : null; + final measurementPoint1 = + isCustomMapMode && drawingProvider.measurementPoint1 != null + ? _toDisplayMapPoint( + customMapConfig, + drawingProvider.measurementPoint1!, + ) + : drawingProvider.measurementPoint1; + final measurementPoint2 = + isCustomMapMode && drawingProvider.measurementPoint2 != null + ? _toDisplayMapPoint( + customMapConfig, + drawingProvider.measurementPoint2!, + ) + : drawingProvider.measurementPoint2; + final calibrationPointA = + isCustomMapMode && _customMapCalibrationPointA != null + ? _toDisplayMapPoint(customMapConfig, _customMapCalibrationPointA!) + : null; + final calibrationPointB = + isCustomMapMode && _customMapCalibrationPointB != null + ? _toDisplayMapPoint(customMapConfig, _customMapCalibrationPointB!) + : null; + + return Stack( + children: [ + // Map widget + Listener( + onPointerMove: (PointerMoveEvent event) { + // Track pointer movement for mobile drag (onPointerHover doesn't work on mobile) + if (_isDraggingPin && !isCustomMapMode) { + final latLng = _mapController.camera.screenOffsetToLatLng( + event.localPosition, + ); + setState(() { + _droppedPinLocation = latLng; + }); + } + }, + child: FlutterMap( + mapController: _mapController, + options: MapOptions( + crs: isCustomMapMode + ? const CrsSimple() + : _currentLayer.crs ?? const Epsg3857(), + initialCenter: isCustomMapMode + ? center + : (_savedMapCenter ?? center), + initialZoom: isCustomMapMode + ? 0.0 + : (_savedMapZoom ?? _defaultZoom), + initialCameraFit: isCustomMapMode + ? CameraFit.bounds( + bounds: customMapConfig.displayBounds, + padding: const EdgeInsets.all(24), + ) + : null, + minZoom: isCustomMapMode ? _customMapMinZoom : 0, + maxZoom: isCustomMapMode + ? _customMapMaxZoom + : _currentLayer.maxZoom, + cameraConstraint: isCustomMapMode + ? CameraConstraint.containCenter( + bounds: customMapConfig.displayBounds, + ) + : const CameraConstraint.unconstrained(), + interactionOptions: InteractionOptions( + flags: _isDraggingPin && !isCustomMapMode + ? InteractiveFlag + .none // Disable map interaction while dragging pin + : InteractiveFlag.all, + ), + onMapEvent: (event) { + // Save map position when user stops panning/zooming + if (event is MapEventMoveEnd || + event is MapEventScrollWheelZoom) { + _saveMapPosition(); + } + // Trigger rebuild on rotation change to show/hide reset button + if (event is MapEventRotateEnd || + event is MapEventRotateStart) { + setState(() {}); + } + }, + onLongPress: (tapPosition, point) { + if (_isCalibratingCustomMap) { + return; + } + + final mapPoint = isCustomMapMode + ? _toStoredMapPoint(customMapConfig, point) + : point; + + // Handle measurement mode - set measurement points only, no SAR marker + if (drawingProvider.drawingMode == DrawingMode.measure) { + if (drawingProvider.measurementPoint1 == null) { + // Set first measurement point + drawingProvider.setMeasurementPoint1(mapPoint); + } else if (drawingProvider.measurementPoint2 == null) { + // Set second measurement point + drawingProvider.setMeasurementPoint2(mapPoint); + } else { + // Clear and start new measurement + drawingProvider.clearMeasurement(); + drawingProvider.setMeasurementPoint1(mapPoint); + } + // Don't drop SAR marker pin in measurement mode + return; + } + + // Skip if in other drawing modes + if (drawingProvider.isDrawing) return; + + if (isCustomMapMode) { + _showCustomMapSarDialogWithPoint( + customMapConfig, + mapPoint, + ); + return; + } + + // Drop a pin at long press location for SAR marker creation + if (_droppedPinLocation == null) { + setState(() { + _droppedPinLocation = point; + }); + } + }, + onPointerDown: (event, point) { + if (isCustomMapMode) { + return; + } + // Check if pointer is near the pin to start dragging + if (_droppedPinLocation != null) { + final distance = _calculateDistanceInMeters( + _droppedPinLocation!.latitude, + _droppedPinLocation!.longitude, + point.latitude, + point.longitude, + ); + // If within ~50m of pin, start dragging + if (distance <= 50) { + setState(() { + _isDraggingPin = true; + }); + } + } + }, + onPointerHover: (event, point) { + final mapPoint = isCustomMapMode + ? _toStoredMapPoint(customMapConfig, point) + : point; + + // Update rectangle preview while dragging + if (drawingProvider.drawingMode == DrawingMode.rectangle && + drawingProvider.rectangleStartPoint != null) { + drawingProvider.updateRectangleEndPoint(mapPoint); + return; + } + + // Update pin location while dragging + if (_isDraggingPin && !isCustomMapMode) { + setState(() { + _droppedPinLocation = point; + }); + } + }, + onPointerUp: (event, point) { + // Stop dragging on pointer release + if (_isDraggingPin) { + setState(() { + _isDraggingPin = false; + }); + } + }, + onTap: (tapPosition, point) { + final mapPoint = isCustomMapMode + ? _toStoredMapPoint(customMapConfig, point) + : point; + + if (_isCalibratingCustomMap && isCustomMapMode) { + _handleCustomMapCalibrationTap(mapProvider, mapPoint); + return; + } + + final offlineProvider = context + .read(); + if (!isCustomMapMode && + offlineProvider.drawingMode != + offline.DrawingMode.none) { + offlineProvider.addVertex(point); + return; + } + + // Handle drawing mode taps + if (drawingProvider.drawingMode == DrawingMode.line) { + if (drawingProvider.currentLinePoints.isEmpty) { + // Start new line + drawingProvider.startLine(mapPoint); + } else { + // Add point to current line + drawingProvider.addLinePoint(mapPoint); + } + return; + } else if (drawingProvider.drawingMode == + DrawingMode.rectangle) { + if (drawingProvider.rectangleStartPoint == null) { + // Start rectangle + drawingProvider.startRectangle(mapPoint); + } else { + // Complete rectangle + drawingProvider.completeRectangle(mapPoint); + } + return; + } + + if (isCustomMapMode) { + return; + } + + // Clear dropped pin if tapping elsewhere (not on the pin itself) + if (_droppedPinLocation != null && !_isDraggingPin) { + // Check if tap is far from the pin + final distance = _calculateDistanceInMeters( + _droppedPinLocation!.latitude, + _droppedPinLocation!.longitude, + point.latitude, + point.longitude, + ); + // If tap is more than ~50m away, clear pin + if (distance > 50) { + setState(() { + _droppedPinLocation = null; + }); + } + } + }, + ), + children: [ + // Render raster or WMS tile layer based on layer type + if (isCustomMapMode) + OverlayImageLayer( + overlayImages: [ + OverlayImage( + bounds: customMapConfig.displayBounds, + imageProvider: FileImage( + File(customMapConfig.filePath), + ), + filterQuality: FilterQuality.none, + ), + ], + ) + else if (_currentLayer.isWms && + _currentLayer.wmsBaseUrl != null && + _currentLayer.crs != null) + // WMS Base Layer (e.g., Slovenian Aerial Imagery) + flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: _currentLayer.wmsBaseUrl!, + layers: _currentLayer.wmsLayers ?? [], + styles: _currentLayer.wmsStyles ?? [], + format: _currentLayer.wmsFormat ?? 'image/jpeg', + transparent: _currentLayer.wmsTransparent ?? false, + crs: _currentLayer.crs!, + ), + // Use cached tile provider for offline support + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: _currentLayer.maxZoom, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 WMS Base Layer tile error at ${tile.coordinates}: $error', + ); + }, + ) + else if (!_currentLayer.isWms) + flutter_map.TileLayer( + urlTemplate: _currentLayer.urlTemplate, + tileProvider: _tileProviderFor(_currentLayer), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: _currentLayer.maxZoom, + ), + if (!isCustomMapMode) ...[ + CoverageLayer(currentZoom: _getMapZoom()), + const PolygonDrawLayer(), + const DownloadProgressLayer(), + // WMS Overlays (rendered after base layer, before polylines) + // Note: These overlays only work with EPSG:3794 CRS (Slovenian coordinate system) + // Cadastral parcels overlay + Consumer( + builder: (context, mapProvider, _) { + // Only show if enabled and map is using Slovenian CRS + if (!mapProvider.showCadastralOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + layers: const ['pregledovalnik:kn_parcele'], + styles: const ['parcele'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Cadastral overlay tile error at ${tile.coordinates}: $error', + ); + if (stackTrace != null) { + debugPrint(' StackTrace: $stackTrace'); + } + }, + ); + }, + ), + // Forest roads overlay + Consumer( + builder: (context, mapProvider, _) { + // Only show if enabled and map is using Slovenian CRS + if (!mapProvider.showForestRoadsOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:gozdne_ceste'], + styles: const ['gozdne_ceste'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Forest roads overlay tile error at ${tile.coordinates}: $error', + ); + if (stackTrace != null) { + debugPrint(' StackTrace: $stackTrace'); + } + }, + ); + }, + ), + // Hiking trails overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showHikingTrailsOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const [ + 'pregledovalnik:KGI_LINIJE_PLANINSKE_POTI_G', + ], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Hiking trails overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Main roads overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showMainRoadsOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:KGI_LINIJE_CESTE_G'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Main roads overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // House numbers overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showHouseNumbersOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:NEP_HISNE_STEVILKE'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 House numbers overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Fire hazard zones overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showFireHazardZonesOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:pozarna_ogrozenost'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Fire hazard zones overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Historical fires overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showHistoricalFiresOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:gozdni_pozari'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Historical fires overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Firebreaks overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showFirebreaksOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const [ + 'pregledovalnik:protipozarne_preseke', + ], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Firebreaks overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Kras fire zones overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showKrasFireZonesOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:pozarisce_kras'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Kras fire zones overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Place names overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showPlaceNamesOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:zemljepisna_imena'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Place names overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Municipality borders overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showMunicipalityBordersOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:NEP_RPE_OBCINE'], + styles: const ['obcine'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Municipality borders overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Imported trail layer (rendered at bottom for reference) + Consumer( + builder: (context, mapProvider, _) { + if (mapProvider.importedTrail == null || + mapProvider.importedTrail!.points.length < 2) { + return const SizedBox.shrink(); + } + + return PolylineLayer( + polylines: [ + Polyline( + points: mapProvider.importedTrail!.latLngPoints, + color: Colors.green.withValues(alpha: 0.7), + strokeWidth: 3.0, + borderColor: Colors.white.withValues(alpha: 0.4), + borderStrokeWidth: 1.0, + // DOTTED pattern to distinguish from other trails + pattern: StrokePattern.dotted(spacingFactor: 2), + ), + ], + ); + }, + ), + // Location trail layer (rendered after paths, before drawings) + const LocationTrailLayer(), + ], + // Measurement line layer (rendered before drawings) + if (measurementPoint1 != null && measurementPoint2 != null) + PolylineLayer( + polylines: [ + Polyline( + points: [measurementPoint1, measurementPoint2], + color: Colors.yellow.withValues(alpha: 0.8), + strokeWidth: 3.0, + borderColor: Colors.black.withValues(alpha: 0.5), + borderStrokeWidth: 1.0, + pattern: StrokePattern.dashed(segments: [10, 5]), + ), + ], + ), + if (calibrationPointA != null && calibrationPointB != null) + PolylineLayer( + polylines: [ + Polyline( + points: [calibrationPointA, calibrationPointB], + color: Colors.lightBlueAccent.withValues(alpha: 0.9), + strokeWidth: 3.0, + borderColor: Colors.black.withValues(alpha: 0.35), + borderStrokeWidth: 1.0, + ), + ], + ), + // Drawing layer (rendered after paths, before markers) + DrawingLayer( + drawings: drawingProvider.drawings, + previewDrawing: drawingProvider.getPreviewDrawing(), + pointTransformer: pointTransformer, + ), + MarkerLayer( + markers: [ + // Contact markers + if (!isCustomMapMode) + ..._markerService.generateContactMarkers( + contacts: contactsWithLocation, + context: context, + mapRotation: _getMapRotation(), + userPosition: _locationService.currentPosition, + estimatedLocations: + contactsProvider.estimatedLocations, + onTap: (contact) { + _showDetailedCompassWithContact( + context, + contactsWithLocation, + sarMarkers, + contact, + ); + }, + ), + // SAR markers + ..._markerService.generateSarMarkers( + sarMarkers: sarMarkers, + context: context, + mapRotation: _getMapRotation(), + pointTransformer: pointTransformer, + onTap: (marker) { + _showSarMarkerActions( + marker, + messagesProvider, + contactsProvider, + ); + }, + ), + // User location marker with directional pointer + if (!isCustomMapMode && + _markerService.generateUserLocationMarker( + position: _locationService.currentPosition, + heading: _currentHeading, + context: context, + ) != + null) + _markerService.generateUserLocationMarker( + position: _locationService.currentPosition, + heading: _currentHeading, + context: context, + )!, + // Measurement point 1 marker + if (measurementPoint1 != null) + Marker( + point: measurementPoint1, + width: 60, + height: 80, + rotate: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.yellow.shade700, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Start', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + Container( + decoration: BoxDecoration( + color: Colors.yellow, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: const Icon( + Icons.location_on, + color: Colors.white, + size: 18, + ), + ), + ], + ), + ), + // Measurement point 2 marker + if (measurementPoint2 != null) + Marker( + point: measurementPoint2, + width: 60, + height: 80, + rotate: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.yellow.shade700, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'End', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + Container( + decoration: BoxDecoration( + color: Colors.yellow, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: const Icon( + Icons.location_on, + color: Colors.white, + size: 18, + ), + ), + ], + ), + ), + if (calibrationPointA != null) + Marker( + point: calibrationPointA, + width: 60, + height: 70, + rotate: false, + child: _buildTaggedPointMarker( + label: 'A', + color: Colors.lightBlue, + ), + ), + if (calibrationPointB != null) + Marker( + point: calibrationPointB, + width: 60, + height: 70, + rotate: false, + child: _buildTaggedPointMarker( + label: 'B', + color: Colors.lightBlue, + ), + ), + // Dropped pin marker with label + if (!isCustomMapMode && _droppedPinLocation != null) + Marker( + key: _pinMarkerKey, + point: _droppedPinLocation!, + width: 200, + height: 100, + rotate: false, + child: GestureDetector( + onTap: () { + // Only open dialog if not dragging + if (!_isDraggingPin) { + _showSarDialogWithLocation( + _droppedPinLocation!, + ); + // Clear the pin after opening dialog + setState(() { + _droppedPinLocation = null; + }); + } + }, + child: Opacity( + // Make pin slightly transparent while dragging + opacity: _isDraggingPin ? 0.7 : 1.0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Label + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: _isDraggingPin + ? Colors.orange + : Colors.red, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues( + alpha: 0.3, + ), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + _isDraggingPin + ? AppLocalizations.of( + context, + )!.dragToPosition + : AppLocalizations.of( + context, + )!.createSarMarker, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 4), + // Pin icon pointing down + Icon( + Icons.location_pin, + color: _isDraggingPin + ? Colors.orange + : Colors.red, + size: 48, + shadows: const [ + Shadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + // Drawing markers layer (delete buttons on drawings, only shown when in drawing mode) + DrawingMarkersLayer( + drawings: drawingProvider.drawings, + showDeleteButtons: drawingProvider.isDrawing, + pointTransformer: pointTransformer, + onDeleteDrawing: (drawingId) { + _confirmAndDeleteDrawing(drawingProvider, drawingId); + }, + onTapDrawing: (drawing) { + // Navigate to the corresponding message in Messages tab + if (drawing.messageId != null) { + messagesProvider.navigateToMessage(drawing.messageId!); + widget.onNavigateToMessages?.call(); + } + }, + ), + ], + ), + ), + // Exit fullscreen button - top left (only shown in fullscreen mode) + if (_isFullscreen) + Positioned( + top: 60, + left: 16, + child: FloatingActionButton.small( + heroTag: 'exit_fullscreen', + onPressed: () { + setState(() { + _isFullscreen = false; + }); + _saveSettings(); + // Notify parent about fullscreen change + widget.onFullscreenChanged?.call(false); + }, + backgroundColor: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.9), + child: const Icon(Icons.fullscreen_exit), + ), + ), + // Message overlay - right side (only shown in fullscreen mode on large screens) + if (_isFullscreen && MediaQuery.of(context).size.width >= 800) + Positioned( + top: 60, + bottom: 60, + right: 16, + width: 300, + child: Consumer( + builder: (context, messagesProvider, _) { + // Get last 20 non-system and non-drawing messages, sorted chronologically + final recentMessages = + messagesProvider.messages + .where((m) => !m.isSystemMessage && !m.isDrawing) + .toList() + ..sort((a, b) => a.sentAt.compareTo(b.sentAt)); + final displayMessages = recentMessages.length > 20 + ? recentMessages.sublist(recentMessages.length - 20) + : recentMessages; + + return MapMessageOverlay( + messages: displayMessages, + onNavigateToMessages: widget.onNavigateToMessages, + onMessageTap: (messageId) { + messagesProvider.navigateToMessage(messageId); + widget.onNavigateToMessages?.call(); + }, + ); + }, + ), + ), + // Compass widget - top right (hidden in fullscreen mode) + if (!_isFullscreen && !isCustomMapMode) + Positioned( + top: 16, + right: 16, + child: GestureDetector( + onTap: () => _showDetailedCompass( + context, + contactsWithLocation, + sarMarkers, + ), + child: CompassWidget( + heading: _currentHeading ?? 0, + hasHeading: _currentHeading != null, + ), + ), + ), + if (isCustomMapMode && + !_isFullscreen && + drawingProvider.drawingMode != DrawingMode.measure) + Positioned( + top: 16, + left: 16, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.96), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + customMapConfig.isCalibrated + ? 'Scale set' + : 'Not calibrated', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (_isCalibratingCustomMap) ...[ + const SizedBox(height: 4), + Text( + _customMapCalibrationPointA == null + ? 'Tap point A' + : _customMapCalibrationPointB == null + ? 'Tap point B' + : 'Enter distance', + ), + ], + ], + ), + ), + ), + // Measurement distance overlay + if (drawingProvider.drawingMode == DrawingMode.measure) + Positioned( + top: 16, + left: 16, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: Colors.yellow.shade700.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.straighten, + color: Colors.white, + size: 20, + ), + SizedBox(width: 8), + Text( + AppLocalizations.of(context)!.measureDistance, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + if (isCustomMapMode) ...[ + Text( + customMapConfig.isCalibrated + ? 'Scale set' + : 'Set scale in Layers to enable distance', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ], + if (drawingProvider.measuredDistance != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.distanceLabel( + _formatDistance( + drawingProvider.measuredDistance!, + ), + ), + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + AppLocalizations.of( + context, + )!.longPressToStartNewMeasurement, + style: const TextStyle( + color: Colors.white70, + fontSize: 10, + ), + ), + ], + ) + else if (drawingProvider.measurementPoint1 != null) + Text( + AppLocalizations.of(context)!.longPressForSecondPoint, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ) + else + Text( + AppLocalizations.of( + context, + )!.longPressToStartMeasurement, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ), + ), + if (!isCustomMapMode && !_isFullscreen) + _OfflineSelectionControls( + onConfirm: _confirmOfflineSelection, + onCurrentView: _setOfflineSelectionToCurrentView, + ), + // Map controls - right side (hidden in fullscreen mode) + if (!_isFullscreen) + Positioned( + bottom: 16, + right: 16, + child: Column( + children: [ + const DrawingToolbar(), + const SizedBox(height: 8), + // Hide other buttons when in drawing mode + if (!drawingProvider.isDrawing && !isCustomMapMode) ...[ + // Current Location - always center to GPS + FloatingActionButton.small( + heroTag: 'center_map', + onPressed: !_isMapReady + ? null + : () async { + // Get current zoom to retain it + final currentZoom = _mapController.camera.zoom; + + // Force update GPS location and jump to it + final position = await _locationService + .getCurrentPosition(); + if (position != null && mounted) { + setState(() { + // Position updated in service + }); + _mapController.move( + LatLng( + position.latitude, + position.longitude, + ), + currentZoom, + ); + } else { + // Fallback to cached position or default center + final currentPosition = + _locationService.currentPosition; + if (currentPosition != null) { + _mapController.move( + LatLng( + currentPosition.latitude, + currentPosition.longitude, + ), + currentZoom, + ); + } else { + _mapController.move(center, currentZoom); + } + } + }, + child: const Icon(Icons.my_location), + ), + const SizedBox(height: 8), + // Map Rotation Lock - toggle rotate with heading + FloatingActionButton.small( + heroTag: 'rotation_lock', + backgroundColor: _rotateMarkerWithHeading + ? Theme.of(context).colorScheme.primary + : null, + onPressed: !_isMapReady + ? null + : () { + setState(() { + _rotateMarkerWithHeading = + !_rotateMarkerWithHeading; + // Reset map rotation when disabling + if (_isMapReady) { + try { + final camera = _mapController.camera; + if (!_rotateMarkerWithHeading) { + // Disable: reset to north + _mapController.moveAndRotate( + camera.center, + camera.zoom, + 0, + ); + } else if (_currentHeading != null) { + // Enable: apply current heading rotation + _mapController.moveAndRotate( + camera.center, + camera.zoom, + -_currentHeading!, + ); + } + } catch (e) { + debugPrint( + 'Failed to toggle rotation lock: $e', + ); + } + } + }); + _saveSettings(); + }, + child: Icon( + Icons.screen_lock_rotation, + color: _rotateMarkerWithHeading ? Colors.white : null, + ), + ), + const SizedBox(height: 8), + ], + FloatingActionButton.small( + heroTag: 'ruler_tool', + backgroundColor: + drawingProvider.drawingMode == DrawingMode.measure + ? Theme.of(context).colorScheme.primary + : null, + onPressed: () { + if (drawingProvider.drawingMode == + DrawingMode.measure) { + drawingProvider.exitDrawingMode(); + } else { + drawingProvider.setDrawingMode(DrawingMode.measure); + } + }, + child: Icon( + Icons.straighten, + color: + drawingProvider.drawingMode == DrawingMode.measure + ? Colors.white + : null, + ), + ), + if (!drawingProvider.isDrawing) const SizedBox(height: 8), + // Continue with other buttons when not in drawing mode + if (!drawingProvider.isDrawing) ...[ + // Trail controls button + if (!isCustomMapMode) ...[ + const TrailControls(), + const SizedBox(height: 8), + ], + FloatingActionButton.small( + heroTag: 'layer_selector', + onPressed: () => _showLayerSelector(context), + child: const Icon(Icons.layers), + ), + const SizedBox(height: 8), + FloatingActionButton.small( + heroTag: 'fullscreen_toggle', + onPressed: () { + setState(() { + _isFullscreen = !_isFullscreen; + }); + _saveSettings(); + widget.onFullscreenChanged?.call(_isFullscreen); + }, + child: Icon( + _isFullscreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + ), + ), + ], + ], + ), + ), + // Map debug info - bottom left (hidden in fullscreen mode) + if (_showMapDebugInfo && _isMapReady && !_isFullscreen) + Positioned( + bottom: 16, + left: 16, + child: MapDebugInfo(mapController: _mapController), + ), + ], + ); + }, + ); + } +} + +class _OfflineSelectionControls extends StatelessWidget { + final void Function( + BuildContext context, + offline.OfflineTilesProvider provider, + ) + onConfirm; + final void Function(offline.OfflineTilesProvider provider) onCurrentView; + + const _OfflineSelectionControls({ + required this.onConfirm, + required this.onCurrentView, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + final hasDraft = + provider.currentVertices.isNotEmpty || + provider.rectangleFirstCorner != null; + if (provider.drawingMode == offline.DrawingMode.none && + !provider.hasPolygons && + !hasDraft) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + final title = switch (provider.drawingMode) { + offline.DrawingMode.rectangle => + provider.rectangleFirstCorner == null + ? 'Tap first corner' + : 'Tap opposite corner', + offline.DrawingMode.polygon => + '${provider.currentVertices.length} polygon points', + offline.DrawingMode.none => + '${provider.polygons.length} area selected', + }; + + return Positioned( + left: 16, + right: 88, + bottom: 16, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface.withValues(alpha: 0.96), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + provider.drawingMode == offline.DrawingMode.none + ? Icons.check_circle + : Icons.edit_location_alt, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text(title, style: theme.textTheme.labelLarge), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (provider.drawingMode == offline.DrawingMode.polygon) + FilledButton.icon( + onPressed: provider.currentVertices.length >= 3 + ? () => onConfirm(context, provider) + : null, + icon: const Icon(Icons.check, size: 18), + label: const Text('Confirm'), + ) + else if (provider.hasPolygons) + FilledButton.icon( + onPressed: () { + onConfirm(context, provider); + }, + icon: const Icon(Icons.check, size: 18), + label: const Text('Confirm'), + ), + if (provider.hasPolygons || hasDraft) + OutlinedButton.icon( + onPressed: provider.clearPolygons, + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Clear'), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _MapZoomSelector extends StatelessWidget { + final String label; + final int value; + final ValueChanged onChanged; + + const _MapZoomSelector({ + 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, + ), + ), + ], + ); + } +} diff --git a/lib/screens/mcp.json b/lib/screens/mcp.json new file mode 100644 index 0000000..a3019b9 --- /dev/null +++ b/lib/screens/mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "dart": { + "command": "dart", + "args": [ + "mcp-server" + ] + } + } +} \ No newline at end of file diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart new file mode 100644 index 0000000..4289b7f --- /dev/null +++ b/lib/screens/messages_tab.dart @@ -0,0 +1,3065 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' show Platform; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:meshcore_client/meshcore_client.dart' show MeshCoreConstants; +import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; +import '../providers/messages_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../providers/map_provider.dart'; +import '../providers/connection_provider.dart'; +import '../providers/drawing_provider.dart'; +import '../providers/voice_provider.dart'; +import '../providers/app_provider.dart'; +import '../models/message.dart'; +import '../models/contact.dart'; +import '../widgets/messages/sar_update_sheet.dart'; +import '../widgets/messages/recipient_selector_sheet.dart'; +import '../widgets/messages/messages_composer.dart'; +import '../widgets/messages/messages_content.dart'; +import '../widgets/common/contact_avatar.dart'; +import '../services/message_destination_preferences.dart'; +import '../services/voice_bitrate_preferences.dart'; +import '../services/voice_recorder_service.dart'; +import '../services/voice_codec_service.dart'; +import '../utils/advert_helper.dart'; +import '../utils/toast_logger.dart'; +import '../utils/key_comparison.dart'; +import '../utils/contact_sorting.dart'; +import '../utils/voice_message_parser.dart'; +import '../utils/image_message_parser.dart'; +import '../utils/tictactoe_message_parser.dart'; +import '../providers/image_provider.dart' as ip; +import '../services/image_codec_service.dart'; +import '../services/image_preferences.dart'; +import '../services/region_scope_preferences.dart'; +import 'package:image_picker/image_picker.dart'; +import '../l10n/app_localizations.dart'; + +class MessagesTab extends StatefulWidget { + final VoidCallback? onNavigateToMap; + final bool isActive; + + const MessagesTab({super.key, this.onNavigateToMap, this.isActive = true}); + + @override + State createState() => _MessagesTabState(); +} + +class _ComposerActionTile extends StatelessWidget { + final IconData icon; + final String title; + final Color color; + final bool enabled; + final bool busy; + final VoidCallback? onTap; + + const _ComposerActionTile({ + required this.icon, + required this.title, + required this.color, + this.enabled = true, + this.busy = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final effectiveColor = enabled + ? color + : colorScheme.onSurfaceVariant.withValues(alpha: 0.45); + + return Material( + color: enabled + ? colorScheme.surfaceContainerLow + : colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(20), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + enabled: enabled, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + onTap: enabled ? onTap : null, + leading: Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: enabled + ? effectiveColor.withValues(alpha: 0.16) + : colorScheme.outlineVariant.withValues(alpha: 0.16), + ), + ), + alignment: Alignment.center, + child: Icon(icon, color: effectiveColor, size: 20), + ), + title: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: enabled + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant, + ), + ), + trailing: busy + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: effectiveColor, + ), + ) + : Icon( + Icons.chevron_right_rounded, + color: colorScheme.onSurfaceVariant, + ), + ), + ); + } +} + +class _MessagesTabState extends State { + static const int _maxContactMessageBytes = 156; + static const int _maxChannelMessageBytes = 127; + static const double _composerOverlayHeight = 148; + static const Duration _channelAutoReadDelay = Duration(seconds: 5); + + final TextEditingController _textController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final ScrollController _scrollController = ScrollController(); + int _messageByteCount = 0; + String? _highlightedMessageId; + Timer? _highlightTimer; // Timer for clearing message highlight + Timer? _channelReadTimer; + String? _pendingChannelReadKey; + bool _suppressMentionTrigger = false; + TextRange? _activeMentionRange; + String _mentionQuery = ''; + List _mentionSuggestions = const []; + ContactsProvider? _contactsProvider; + AppProvider? _appProvider; + bool? _lastKnownSimpleMode; + + // Message destination state + String _destinationType = + MessageDestinationPreferences.destinationTypeChannel; + Contact? _selectedRecipient; + bool _isDestinationLocked = false; + + // Region scope state + String? _channelRegionScopeName; + Uint8List? _channelRegionScopeKey; + Map _channelRegionScopes = {}; + + // Image sending state + bool _isSendingImage = false; + final ImagePicker _imagePicker = ImagePicker(); + + // Voice recording state + final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); + bool _isRecording = false; + bool _isSendingVoice = false; + static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30); + static const double _silenceRmsThreshold = 500.0; + static const double _silencePeakThreshold = 1400.0; + static const int _maxInteriorSilentChunks = 2; + bool get _voiceSupported => !kIsWeb && (Platform.isIOS || Platform.isAndroid); + StreamSubscription? _voiceStreamSub; + String? _currentVoiceSessionId; + final List _recordedChunks = []; + VoicePacketMode? _activeVoiceMode; + int _selectedVoiceBitrate = VoiceBitratePreferences.defaultBitrate; + + @override + void initState() { + super.initState(); + _textController.addListener(_handleComposerChanged); + _focusNode.addListener(_handleFocusChanged); + _loadVoiceSettings(); + _loadAllChannelRegionScopes(); + _scheduleDestinationSync(); + } + + Future _loadVoiceSettings() async { + final bitrate = await VoiceBitratePreferences.getBitrate(); + if (!mounted) return; + setState(() { + _selectedVoiceBitrate = bitrate; + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final contactsProvider = context.read(); + if (!identical(_contactsProvider, contactsProvider)) { + _contactsProvider?.removeListener(_handleContactsChanged); + _contactsProvider = contactsProvider; + _contactsProvider?.addListener(_handleContactsChanged); + } + final appProvider = context.read(); + if (!identical(_appProvider, appProvider)) { + _appProvider?.removeListener(_handleSimpleModeChanged); + _appProvider = appProvider; + _lastKnownSimpleMode = appProvider.isSimpleMode; + _appProvider?.addListener(_handleSimpleModeChanged); + } + _scheduleDestinationSync(); + } + + @override + void dispose() { + _highlightTimer?.cancel(); + _channelReadTimer?.cancel(); + _voiceStreamSub?.cancel(); + _voiceRecorder.dispose(); + _contactsProvider?.removeListener(_handleContactsChanged); + _appProvider?.removeListener(_handleSimpleModeChanged); + _focusNode.removeListener(_handleFocusChanged); + _textController.dispose(); + _focusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant MessagesTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isActive != widget.isActive) { + _syncChannelAutoReadTimer(context.read()); + if (widget.isActive) { + _scheduleDestinationSync(); + } + } + } + + void _scheduleDestinationSync() { + WidgetsBinding.instance.addPostFrameCallback((_) { + _synchronizeDestinationState(); + }); + } + + void _handleContactsChanged() { + if (!mounted) return; + _scheduleDestinationSync(); + } + + void _handleSimpleModeChanged() { + if (!mounted) return; + final simpleMode = _appProvider?.isSimpleMode; + if (simpleMode == _lastKnownSimpleMode) return; + _lastKnownSimpleMode = simpleMode; + _scheduleDestinationSync(); + } + + Future _synchronizeDestinationState() async { + if (!mounted) return; + final messagesProvider = context.read(); + final targetMessageId = messagesProvider.targetMessageId; + final targetDestinationType = messagesProvider.targetDestinationType; + final targetRecipientPublicKeyHex = + messagesProvider.targetRecipientPublicKeyHex; + + await _restoreDestinationState( + overrideType: targetDestinationType, + overrideRecipientPublicKeyHex: targetRecipientPublicKeyHex, + ); + + if (!mounted) return; + + if (targetMessageId != null) { + _scrollToMessage(targetMessageId); + messagesProvider.clearMessageNavigation(); + } + + if (targetDestinationType != null) { + messagesProvider.clearDestinationNavigation(); + _focusNode.requestFocus(); + } + } + + void _scrollToMessage(String messageId) { + final messagesProvider = context.read(); + final messages = messagesProvider.buildDisplayMessages( + _getFilteredMessages(messagesProvider), + ); + + final messageIndex = messages.indexWhere( + (entry) => entry.message.id == messageId, + ); + + if (messageIndex != -1 && _scrollController.hasClients) { + // Calculate position - accounting for reverse list + final itemHeight = 80.0; // Approximate height of a message bubble + final targetOffset = messageIndex * itemHeight; + + // Scroll to the message + _scrollController.animateTo( + targetOffset, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + + // Highlight the message briefly + setState(() { + _highlightedMessageId = messageId; + }); + + // Clear highlight after 2 seconds using a properly managed Timer + _highlightTimer?.cancel(); + _highlightTimer = Timer(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + _highlightedMessageId = null; + }); + } + }); + } + } + + void _handleComposerChanged() { + _updateCharacterCount(); + + if (_suppressMentionTrigger) { + return; + } + + _updateMentionSuggestions(_textController.value); + } + + void _updateCharacterCount() { + setState(() { + _messageByteCount = utf8.encode(_textController.text).length; + }); + } + + void _handleFocusChanged() { + if (!_focusNode.hasFocus) { + _clearMentionSuggestions(); + return; + } + + if (_suppressMentionTrigger) { + return; + } + + _updateMentionSuggestions(_textController.value); + } + + void _updateMentionSuggestions(TextEditingValue value) { + final triggerRange = _getMentionTriggerRange(value); + if (triggerRange == null) { + _clearMentionSuggestions(); + return; + } + + final query = value.text.substring( + triggerRange.start + 1, + triggerRange.end, + ); + final contacts = _buildMentionSuggestions(query); + if (!mounted) { + return; + } + + setState(() { + _activeMentionRange = triggerRange; + _mentionQuery = query; + _mentionSuggestions = contacts; + }); + } + + void _clearMentionSuggestions() { + if (_activeMentionRange == null && + _mentionQuery.isEmpty && + _mentionSuggestions.isEmpty) { + return; + } + + setState(() { + _activeMentionRange = null; + _mentionQuery = ''; + _mentionSuggestions = const []; + }); + } + + TextRange? _getMentionTriggerRange(TextEditingValue value) { + if (!value.selection.isValid || !value.selection.isCollapsed) { + return null; + } + + final cursorOffset = value.selection.baseOffset; + if (cursorOffset <= 0) { + return null; + } + + final text = value.text; + final triggerStart = text.lastIndexOf('@', cursorOffset - 1); + if (triggerStart == -1) { + return null; + } + + final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null; + if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) { + return null; + } + + final query = text.substring(triggerStart + 1, cursorOffset); + if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) { + return null; + } + + return TextRange(start: triggerStart, end: cursorOffset); + } + + List _buildMentionSuggestions(String query) { + final contactsProvider = context.read(); + final normalizedQuery = query.trim().toLowerCase(); + final candidates = context.read().isSimpleMode + ? contactsProvider.favouriteChatContacts + : contactsProvider.chatContacts; + final contacts = + candidates.where((contact) { + if (normalizedQuery.isEmpty) return true; + return contact.displayName.toLowerCase().contains(normalizedQuery); + }).toList()..sort((a, b) { + final primary = compareContactsByFavouriteThenLastSeen(a, b); + if (primary != 0) { + return primary; + } + + final aName = a.displayName.toLowerCase(); + final bName = b.displayName.toLowerCase(); + final aStarts = + normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery); + final bStarts = + normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery); + if (aStarts != bStarts) { + return aStarts ? -1 : 1; + } + + return compareContactsByDisplayName(a, b); + }); + + return contacts.take(8).toList(growable: false); + } + + int get _maxMessageBytes => + _destinationType == MessageDestinationPreferences.destinationTypeChannel + ? _maxChannelMessageBytes + : _maxContactMessageBytes; + + TextInputFormatter get _messageByteLimiter => + TextInputFormatter.withFunction((oldValue, newValue) { + if (utf8.encode(newValue.text).length <= _maxMessageBytes) { + return newValue; + } + return oldValue; + }); + + void _enforceMessageByteLimit() { + final currentText = _textController.text; + if (utf8.encode(currentText).length <= _maxMessageBytes) { + _updateCharacterCount(); + return; + } + + var truncated = currentText; + while (truncated.isNotEmpty && + utf8.encode(truncated).length > _maxMessageBytes) { + truncated = truncated.substring(0, truncated.length - 1); + } + + _textController.value = _textController.value.copyWith( + text: truncated, + selection: TextSelection.collapsed(offset: truncated.length), + composing: TextRange.empty, + ); + _updateCharacterCount(); + } + + Future _restoreDestinationState({ + String? overrideType, + String? overrideRecipientPublicKeyHex, + }) async { + final lockedDestination = + await MessageDestinationPreferences.getLockedDestination(); + final savedDestination = + await MessageDestinationPreferences.getDestination(); + final effectiveType = + overrideType ?? + lockedDestination?['type'] ?? + savedDestination?['type'] ?? + MessageDestinationPreferences.destinationTypeChannel; + final effectivePublicKeyHex = + overrideRecipientPublicKeyHex ?? + lockedDestination?['publicKey'] ?? + savedDestination?['publicKey']; + if (!mounted) return; + final contactsProvider = context.read(); + + if (context.read().isSimpleMode) { + // Simple mode allows the locked channel/room plus direct messages to + // favourite contacts. A favourite chosen by the user (override or + // saved selection) wins over the locked anchor; anything else falls + // back to the locked destination, then to the first favourite. The + // fallback is never persisted over the normal-mode preference. + final favourites = contactsProvider.favouriteChatContacts; + final candidateType = overrideType ?? savedDestination?['type']; + final candidateKey = + overrideRecipientPublicKeyHex ?? savedDestination?['publicKey']; + + Contact? favourite; + if (candidateType == + MessageDestinationPreferences.destinationTypeContact) { + favourite = favourites + .where((c) => c.publicKeyHex == candidateKey) + .firstOrNull; + } + + String destinationType; + Contact? recipient; + if (favourite != null) { + destinationType = MessageDestinationPreferences.destinationTypeContact; + recipient = favourite; + } else if (lockedDestination != null) { + destinationType = + lockedDestination['type'] ?? + MessageDestinationPreferences.destinationTypeChannel; + recipient = _resolveDestinationRecipient( + contactsProvider, + destinationType, + lockedDestination['publicKey'], + ); + } else { + destinationType = MessageDestinationPreferences.destinationTypeContact; + recipient = favourites.firstOrNull; + } + + setState(() { + // Keep the selector usable so favourites stay reachable. + _isDestinationLocked = false; + _destinationType = destinationType; + _selectedRecipient = recipient; + }); + + _enforceMessageByteLimit(); + await _loadRegionScope(); + return; + } + + final recipient = _resolveDestinationRecipient( + contactsProvider, + effectiveType, + effectivePublicKeyHex, + ); + final allowsEmptyRecipient = + effectiveType == MessageDestinationPreferences.destinationTypeAll || + (effectiveType == + MessageDestinationPreferences.destinationTypeChannel && + effectivePublicKeyHex == null); + final shouldFallbackToPublicChannel = + recipient == null && !allowsEmptyRecipient; + final destinationType = shouldFallbackToPublicChannel + ? MessageDestinationPreferences.destinationTypeChannel + : effectiveType; + final selectedRecipient = shouldFallbackToPublicChannel ? null : recipient; + final shouldClearSavedDestination = + lockedDestination == null && + overrideType == null && + shouldFallbackToPublicChannel && + savedDestination != null; + + if (!mounted) return; + + setState(() { + _isDestinationLocked = lockedDestination != null; + _destinationType = destinationType; + _selectedRecipient = selectedRecipient; + }); + + if (shouldClearSavedDestination) { + await MessageDestinationPreferences.clearDestination(); + } + + _enforceMessageByteLimit(); + await _loadRegionScope(); + } + + Contact? _resolveDestinationRecipient( + ContactsProvider contactsProvider, + String type, + String? publicKeyHex, + ) { + if (publicKeyHex == null) { + return null; + } + + final candidates = switch (type) { + MessageDestinationPreferences.destinationTypeChannel => + contactsProvider.channels, + MessageDestinationPreferences.destinationTypeRoom => + contactsProvider.rooms, + MessageDestinationPreferences.destinationTypeContact => + contactsProvider.chatContacts, + _ => contactsProvider.contacts, + }; + + return candidates.where((contact) { + return contact.publicKeyHex == publicKeyHex; + }).firstOrNull; + } + + /// Show recipient selector bottom sheet + Future _showRecipientSelector() async { + if (_isDestinationLocked) { + return; + } + + final contactsProvider = context.read(); + final messagesProvider = context.read(); + final isSimpleMode = context.read().isSimpleMode; + + // In simple mode the only channel/room offered is the locked anchor, so + // the user can return to the team channel after messaging a favourite. + Contact? lockedRecipient; + String? lockedType; + if (isSimpleMode) { + final lockedDestination = + await MessageDestinationPreferences.getLockedDestination(); + if (lockedDestination != null) { + lockedType = + lockedDestination['type'] ?? + MessageDestinationPreferences.destinationTypeChannel; + lockedRecipient = _resolveDestinationRecipient( + contactsProvider, + lockedType, + lockedDestination['publicKey'], + ); + } + } + if (!mounted) return; + + final contacts = + List.from( + isSimpleMode + ? contactsProvider.favouriteChatContacts + : contactsProvider.chatContacts, + )..sort((a, b) { + final primary = compareContactsByFavouriteThenLastSeen(a, b); + if (primary != 0) { + return primary; + } + + return compareContactsByDisplayName(a, b); + }); + final rooms = + isSimpleMode + ? [ + if (lockedRecipient != null && + lockedType == + MessageDestinationPreferences.destinationTypeRoom) + lockedRecipient, + ] + : List.from(contactsProvider.rooms) + ..sort((a, b) { + final primary = compareContactsByLastSeen(a, b); + if (primary != 0) { + return primary; + } + + return compareContactsByDisplayName(a, b); + }); + final channels = + isSimpleMode + ? [ + if (lockedRecipient != null && + lockedType == + MessageDestinationPreferences.destinationTypeChannel) + lockedRecipient, + ] + : List.from(contactsProvider.channels) + ..sort((a, b) { + final primary = compareContactsByLastSeen(a, b); + if (primary != 0) { + return primary; + } + + return compareContactsByDisplayName(a, b); + }); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => RecipientSelectorSheet( + contacts: contacts, + rooms: rooms, + channels: channels, + unreadCount: messagesProvider.unreadCount, + unreadCountsByPublicKey: { + for (final contact in [...contacts, ...rooms, ...channels]) + contact.publicKeyHex: messagesProvider.getUnreadCountForDestination( + contact, + ), + }, + currentDestinationType: _destinationType, + currentRecipientPublicKey: _selectedRecipient?.publicKeyHex, + onSelect: _onRecipientSelected, + channelRegionScopes: _channelRegionScopes, + ), + ); + } + + /// Handle recipient selection + Future _onRecipientSelected( + String type, + Contact? recipient, { + bool persistSelection = true, + }) async { + setState(() { + _destinationType = type; + _selectedRecipient = recipient; + }); + + _markCurrentDestinationAsRead(); + + _enforceMessageByteLimit(); + + // Load region scope for channel destinations + await _loadRegionScope(); + + if (persistSelection) { + await MessageDestinationPreferences.setDestination( + type, + recipientPublicKey: recipient?.publicKeyHex, + ); + } + + // Show confirmation toast + if (!mounted) return; + } + + Future _loadRegionScope() async { + if (_destinationType != + MessageDestinationPreferences.destinationTypeChannel) { + if (_channelRegionScopeName != null) { + setState(() { + _channelRegionScopeName = null; + _channelRegionScopeKey = null; + }); + } + return; + } + final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; + final scope = await RegionScopePreferences.getScope(channelIdx); + if (!mounted) return; + setState(() { + _channelRegionScopeName = scope?.name; + _channelRegionScopeKey = scope?.key; + if (scope != null) { + _channelRegionScopes[channelIdx] = scope.name; + } else { + _channelRegionScopes.remove(channelIdx); + } + }); + } + + Future _loadAllChannelRegionScopes() async { + final contactsProvider = context.read(); + final channels = contactsProvider.chatContacts + .where((c) => c.publicKey.length > 1 && c.publicKey[0] == 0) + .toList(); + final scopes = {}; + // Always check channel 0 (public) + final scope0 = await RegionScopePreferences.getScope(0); + if (scope0 != null) scopes[0] = scope0.name; + for (final ch in channels) { + final idx = ch.publicKey[1]; + final scope = await RegionScopePreferences.getScope(idx); + if (scope != null) scopes[idx] = scope.name; + } + if (!mounted) return; + setState(() { + _channelRegionScopes = scopes; + }); + } + + void _insertReplyMention(String displayName, {TextRange? replacementRange}) { + final trimmedName = displayName.trim(); + if (trimmedName.isEmpty) return; + + final mention = '@[$trimmedName] '; + final value = _textController.value; + final selection = replacementRange ?? value.selection; + final hasSelection = + selection.isValid && + selection.start >= 0 && + selection.end >= selection.start; + + final start = hasSelection ? selection.start : value.text.length; + final end = hasSelection ? selection.end : value.text.length; + final nextText = value.text.replaceRange(start, end, mention); + final nextOffset = start + mention.length; + + _suppressMentionTrigger = true; + _textController.value = value.copyWith( + text: nextText, + selection: TextSelection.collapsed(offset: nextOffset), + composing: TextRange.empty, + ); + _suppressMentionTrigger = false; + _clearMentionSuggestions(); + _enforceMessageByteLimit(); + } + + void _selectMention(Contact contact) { + _insertReplyMention( + contact.displayName, + replacementRange: _activeMentionRange, + ); + _focusNode.requestFocus(); + } + + Future _replyToMessage(Message message) async { + final contactsProvider = context.read(); + Contact? senderContact; + String? senderDisplayName; + + String destinationType; + Contact? recipient; + + if (message.isChannelMessage) { + final channelIdx = message.channelIdx ?? 0; + destinationType = MessageDestinationPreferences.destinationTypeChannel; + + if (channelIdx != 0) { + recipient = contactsProvider.channels.where((contact) { + return contact.publicKey.length > 1 && + contact.publicKey[1] == channelIdx; + }).firstOrNull; + + if (recipient == null) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.cannotReplyContactNotFound, + ); + return; + } + } + + final senderPrefix = message.senderPublicKeyPrefix; + if (senderPrefix != null && senderPrefix.length >= 6) { + senderContact = contactsProvider.findContactByPrefix(senderPrefix); + } + senderDisplayName = + senderContact?.displayName ?? message.senderName?.trim(); + } else { + final roomRecipient = message.recipientPublicKey == null + ? null + : contactsProvider.findContactByKey(message.recipientPublicKey!); + + if (roomRecipient?.isRoom == true) { + destinationType = MessageDestinationPreferences.destinationTypeRoom; + recipient = roomRecipient; + + final senderPrefix = message.senderPublicKeyPrefix; + if (senderPrefix != null && senderPrefix.length >= 6) { + senderContact = contactsProvider.findContactByPrefix(senderPrefix); + } + senderDisplayName = + senderContact?.displayName ?? message.senderName?.trim(); + } else { + final senderPrefix = message.senderPublicKeyPrefix; + if (senderPrefix == null || senderPrefix.length < 6) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.cannotReplySenderMissing, + ); + return; + } + + recipient = contactsProvider.findContactByPrefix(senderPrefix); + if (recipient == null) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.cannotReplyContactNotFound, + ); + return; + } + + destinationType = recipient.isRoom + ? MessageDestinationPreferences.destinationTypeRoom + : MessageDestinationPreferences.destinationTypeContact; + } + } + + await _onRecipientSelected( + destinationType, + recipient, + persistSelection: !_isDestinationLocked, + ); + if (!mounted) return; + if ((message.isChannelMessage || recipient?.isRoom == true) && + senderDisplayName != null && + senderDisplayName.isNotEmpty) { + _insertReplyMention(senderDisplayName); + } + _focusNode.requestFocus(); + } + + /// Get icon for current destination type + IconData _getDestinationIcon() { + if (_destinationType == MessageDestinationPreferences.destinationTypeAll) { + return Icons.all_inbox; + } else if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) { + return Icons.public; + } else if (_destinationType == + MessageDestinationPreferences.destinationTypeRoom) { + return Icons.meeting_room; + } else { + return Icons.person; + } + } + + String _getDestinationLabel() { + if (_destinationType == MessageDestinationPreferences.destinationTypeAll) { + return AppLocalizations.of(context)!.showAll; + } + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel && + _selectedRecipient != null) { + return _selectedRecipient!.getLocalizedDisplayName(context); + } + if (_selectedRecipient != null) { + return _selectedRecipient!.displayName; + } + if (context.read().isSimpleMode) { + return AppLocalizations.of(context)!.noFavouriteContacts; + } + return AppLocalizations.of(context)!.publicChannel; + } + + Future _sendMessage() async { + final text = _textController.text.trim(); + if (text.isEmpty) return; + + final connectionProvider = context.read(); + final messagesProvider = context.read(); + final contactsProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.notConnectedToDevice, + ); + return; + } + + if (_destinationType == MessageDestinationPreferences.destinationTypeAll) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.selectDestinationFirst, + ); + return; + } + + // Refuse to send if the contact/room destination no longer resolves to a + // recipient - never silently redirect private traffic to the public channel. + if (_destinationType != + MessageDestinationPreferences.destinationTypeChannel && + _selectedRecipient == null) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.recipientNoLongerAvailable, + ); + return; + } + + _textController.clear(); + + try { + // Check destination type and send accordingly + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) { + // Send to selected channel (or public channel if none selected) + final channelIdx = + _selectedRecipient?.publicKey[1] ?? + 0; // Extract channel index from pseudo public key + await _sendToChannel( + text, + connectionProvider, + messagesProvider, + channelIdx, + ); + } else { + // Send to contact or room (recipient presence already validated above) + await _sendToRecipient( + text, + connectionProvider, + messagesProvider, + contactsProvider, + ); + } + + _markCurrentDestinationAsRead(); + + if (!mounted) return; + } catch (e) { + // The placeholder bubble is marked failed by the send helpers, so the + // inline retry chip is the recovery path - don't restore the composer. + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToSendMessage('$e'), + ); + } + } + + /// Send message to channel + Future _sendToChannel( + String text, + ConnectionProvider connectionProvider, + MessagesProvider messagesProvider, + int channelIdx, + ) async { + // Create message ID + final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object + final sentMessage = Message( + id: messageId, + messageType: MessageType.channel, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: text, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + channelIdx: channelIdx, + ); + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient); + + // Send to selected channel (with region scope if set) + try { + await connectionProvider.sendChannelMessage( + channelIdx: channelIdx, + text: text, + messageId: messageId, + floodScopeKey: _channelRegionScopeKey, + ); + } catch (e) { + // Mark the placeholder as failed so the retry chip appears + messagesProvider.markMessageFailed(messageId); + rethrow; + } + } + + /// Send message to contact or room + Future _sendToRecipient( + String text, + ConnectionProvider connectionProvider, + MessagesProvider messagesProvider, + ContactsProvider contactsProvider, + ) async { + if (_selectedRecipient == null) return; + + // Create message ID + final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object with recipient public key for retry support + final sentMessage = Message( + id: messageId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: text, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: _selectedRecipient!.publicKey, + ); + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient); + + // Send message to selected recipient + final bool sentSuccessfully; + try { + sentSuccessfully = await connectionProvider.sendTextMessage( + contactPublicKey: _selectedRecipient!.publicKey, + text: text, + messageId: messageId, + contact: _selectedRecipient, + ); + } catch (e) { + // Mark the placeholder as failed so the retry chip appears + messagesProvider.markMessageFailed(messageId); + rethrow; + } + + if (!sentSuccessfully) { + // Mark message as failed if sending failed + messagesProvider.markMessageFailed(messageId); + } + } + + bool _isContactDestination() { + return _destinationType == + MessageDestinationPreferences.destinationTypeContact && + _selectedRecipient != null; + } + + void _showSendModeSheet() { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + + showModalBottomSheet( + context: context, + builder: (sheetContext) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.route), + title: Text(l10n.autoSend), + subtitle: Text(l10n.autoSendDescription), + onTap: () { + Navigator.pop(sheetContext); + _sendMessage(); + }, + ), + ListTile( + leading: const Icon(Icons.near_me), + title: Text(l10n.sendDirect), + subtitle: Text(l10n.sendDirectDescription), + onTap: () async { + Navigator.pop(sheetContext); + final connectionProvider = context.read(); + if (_selectedRecipient != null && + connectionProvider.deviceInfo.isConnected) { + await connectionProvider.setContactDirect( + _selectedRecipient!, + ); + } + await _sendMessage(); + }, + ), + ListTile( + leading: const Icon(Icons.cell_tower), + title: Text(l10n.sendFlood), + subtitle: Text(l10n.sendFloodDescription), + onTap: () async { + Navigator.pop(sheetContext); + final connectionProvider = context.read(); + if (_selectedRecipient != null && + connectionProvider.deviceInfo.isConnected) { + await connectionProvider.resetPath( + _selectedRecipient!.publicKey, + ); + } + await _sendMessage(); + }, + ), + ], + ), + ); + }, + ); + } + + Future _startTicTacToeGame() async { + if (!mounted) return; + if (_destinationType != + MessageDestinationPreferences.destinationTypeContact || + _selectedRecipient == null) { + ToastLogger.warning( + context, + AppLocalizations.of(context)!.tictactoeDirectOnly, + ); + return; + } + + final connectionProvider = context.read(); + final messagesProvider = context.read(); + final contactsProvider = context.read(); + if (!connectionProvider.deviceInfo.isConnected) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.notConnectedToDevice, + ); + return; + } + + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + if (devicePublicKey == null || devicePublicKey.length < 6) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.deviceKeyUnavailable, + ); + return; + } + + final gameId = List.generate( + 8, + (_) => math.Random.secure().nextInt(16).toRadixString(16), + ).join(); + final starterKey6 = devicePublicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final startMessage = TicTacToeMessageParser.encodeStart( + gameId: gameId, + starterKey6: starterKey6, + timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + await _sendToRecipient( + startMessage, + connectionProvider, + messagesProvider, + contactsProvider, + ); + } + + // ── Image sending ─────────────────────────────────────────────────────────── + + Future _pickAndSendImage({ + ImageSource source = ImageSource.gallery, + }) async { + if (_isSendingImage) return; + final shouldContinue = await _confirmPublicChannelMediaSend('image'); + if (!shouldContinue) return; + if (!mounted) return; + final connectionProvider = context.read(); + final messagesProvider = context.read(); + if (!connectionProvider.deviceInfo.isConnected) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.notConnectedToDevice, + ); + return; + } + + // Pick image. + final picked = await _imagePicker.pickImage(source: source); + if (picked == null) return; + final rawBytes = await picked.readAsBytes(); + + setState(() => _isSendingImage = true); + String? failedMessageId; + try { + // Compress AVIF using user-selected size, compression and color mode. + final maxSize = await ImagePreferences.getMaxSize(); + final compression = await ImagePreferences.getCompression(); + final grayscale = await ImagePreferences.getGrayscale(); + final ultraMode = await ImagePreferences.getUltraMode(); + final effectiveMaxSize = ImagePreferences.effectiveMaxSize( + maxSize, + ultraMode: ultraMode, + ); + final result = await ImageCodecService.compress( + rawBytes, + maxDimension: effectiveMaxSize, + compression: compression, + grayscale: grayscale, + ultraMode: ultraMode, + ); + if (result == null) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.imageCompressionFailed, + ); + return; + } + final compressed = result.bytes; + + // Generate session ID (4 random bytes → 8 hex chars). + final sessionId = List.generate( + 8, + (_) => math.Random().nextInt(16).toRadixString(16), + ).join(); + + // Fragment. + var imageDataBytesPerFragment = ImagePacket.maxDataBytes; + if (_destinationType == + MessageDestinationPreferences.destinationTypeContact && + _selectedRecipient != null && + _selectedRecipient!.routeHasPath) { + imageDataBytesPerFragment = safeImageDataBytesForPath( + _selectedRecipient!.routeHopCount, + ); + } + + final fragments = fragmentImage( + sessionId: sessionId, + format: ImageFormat.avif, + bytes: compressed, + maxDataBytes: imageDataBytesPerFragment, + ); + + if (fragments.isEmpty) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.imageFragmentationFailed, + ); + return; + } + + // Build envelope. + final deviceKey = connectionProvider.deviceInfo.publicKey; + if (deviceKey == null || deviceKey.length < 6) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.deviceKeyUnavailable, + ); + return; + } + final envelope = ImageEnvelope( + sessionId: sessionId, + format: ImageFormat.avif, + total: fragments.length, + width: result.width, + height: result.height, + sizeBytes: compressed.length, + ); + + if (!mounted) return; + + // Cache for deferred serving. + final imageProvider = context.read(); + imageProvider.cacheOutgoingSession(sessionId, fragments, envelope); + + // Add local placeholder message. + final msgId = 'img_${sessionId}_sent'; + failedMessageId = msgId; + final isChannel = + _destinationType == + MessageDestinationPreferences.destinationTypeChannel; + final channelIdx = isChannel + ? (_selectedRecipient?.publicKey[1] ?? 0) + : null; + final recipient = _selectedRecipient; + final placeholder = Message( + id: msgId, + messageType: isChannel ? MessageType.channel : MessageType.contact, + channelIdx: channelIdx, + senderPublicKeyPrefix: deviceKey.sublist(0, 6), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + text: envelope.encode(), + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: isChannel ? null : recipient?.publicKey, + ); + messagesProvider.addSentMessage(placeholder, contact: recipient); + + // Send IE1 envelope via normal message path. + final envelopeText = envelope.encode(); + + if (isChannel) { + await connectionProvider.sendChannelMessage( + channelIdx: channelIdx ?? 0, + text: envelopeText, + messageId: msgId, + floodScopeKey: _channelRegionScopeKey, + ); + } else if (recipient != null) { + final sent = await connectionProvider.sendTextMessage( + contactPublicKey: recipient.publicKey, + text: envelopeText, + messageId: msgId, + contact: recipient, + ); + if (!sent) { + messagesProvider.markMessageFailed(msgId); + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToAnnounceImage, + ); + return; + } + } else { + messagesProvider.markMessageFailed(msgId); + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.noRecipientSelected, + ); + return; + } + + debugPrint( + '📷 [Image] Sent IE1 for session $sessionId: ' + '${fragments.length} fragments, ${compressed.length}B, ' + 'chunk=${imageDataBytesPerFragment}B', + ); + + if (isChannel) { + for (final fragment in fragments) { + await connectionProvider.sendChannelData( + channelIdx: channelIdx ?? 0, + dataType: MeshCoreConstants.dataTypeDev, + payload: fragment.encodeBinary(), + floodScopeKey: _channelRegionScopeKey, + ); + } + } + + // Image fragments are always served on demand after an explicit IR2 + // fetch request, including direct contacts. + } catch (e, st) { + debugPrint('❌ [Image] _pickAndSendImage: $e\n$st'); + if (failedMessageId != null) { + messagesProvider.markMessageFailed(failedMessageId); + } + if (!mounted) return; + ToastLogger.error(context, AppLocalizations.of(context)!.imageSendFailed); + } finally { + if (mounted) setState(() => _isSendingImage = false); + } + } + + // ── Voice recording ──────────────────────────────────────────────────────── + + Future _startVoiceRecording() async { + if (_isSendingVoice || _isRecording) return; + debugPrint('🎙️ [Voice] _startVoiceRecording called'); + // Read fresh voice preferences so settings changes apply immediately. + final selectedBitrate = await VoiceBitratePreferences.getBitrate(); + if (mounted) { + setState(() { + _selectedVoiceBitrate = selectedBitrate; + }); + } else { + _selectedVoiceBitrate = selectedBitrate; + } + final hasPermission = await _voiceRecorder.requestPermission(); + debugPrint('🎙️ [Voice] hasPermission=$hasPermission'); + if (!hasPermission) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.microphonePermissionRequiredForVoice, + ); + return; + } + + if (!mounted) return; + final appProvider = context.read(); + final connectionProvider = context.read(); + debugPrint( + '🎙️ [Voice] isConnected=${connectionProvider.deviceInfo.isConnected}', + ); + if (!connectionProvider.deviceInfo.isConnected) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.notConnectedToDevice, + ); + return; + } + + // Generate a new session ID (4 random bytes → 8 hex chars) + final rng = math.Random.secure(); + _currentVoiceSessionId = List.generate( + 4, + (_) => rng.nextInt(256), + ).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + + _activeVoiceMode = VoiceBitratePreferences.toVoiceMode( + _selectedVoiceBitrate, + ); + final packetDuration = Duration( + milliseconds: _activeVoiceMode!.packetDurationMs, + ); + final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!); + + debugPrint( + '🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets', + ); + + _recordedChunks.clear(); + setState(() => _isRecording = true); + + try { + final stream = _voiceRecorder.startCapture( + chunkDuration: packetDuration, + sampleRateHz: _activeVoiceMode!.sampleRateHz, + enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, + enableCompressor: appProvider.isVoiceCompressorEnabled, + enableLimiter: appProvider.isVoiceLimiterEnabled, + enableAutoGain: appProvider.isVoiceAutoGainEnabled, + enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled, + enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled, + ); + debugPrint('🎙️ [Voice] capture started, listening for chunks...'); + _voiceStreamSub = stream.listen( + (pcmChunk) { + if (!_isRecording) return; + _recordedChunks.add(pcmChunk); + debugPrint( + '🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples', + ); + setState(() {}); + if (_recordedChunks.length >= maxVoicePackets) { + debugPrint('🎙️ [Voice] max packets reached, stopping'); + _stopAndSendVoice(); + } + }, + onError: (e) { + debugPrint('❌ [Voice] stream error: $e'); + _stopAndSendVoice(); + }, + onDone: () => debugPrint('🎙️ [Voice] stream done'), + ); + } catch (e) { + debugPrint('❌ [Voice] startCapture threw: $e'); + if (mounted) setState(() => _isRecording = false); + } + } + + int _maxVoicePacketsForMode(VoicePacketMode mode) { + final packets = + _maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs; + return packets < 1 ? 1 : packets; + } + + Future _stopAndSendVoice() async { + if (!_isRecording) return; + final trimSilenceEnabled = context + .read() + .isVoiceSilenceTrimmingEnabled; + debugPrint( + '🎙️ [Voice] _stopAndSendVoice: ${_recordedChunks.length} chunks buffered', + ); + + await _voiceStreamSub?.cancel(); + _voiceStreamSub = null; + await _voiceRecorder.stopCapture(); + + final rawChunks = List.from(_recordedChunks); + final trimmedChunks = trimSilenceEnabled + ? _trimSilence(rawChunks) + : rawChunks; + final sessionId = _currentVoiceSessionId; + final mode = _activeVoiceMode; + final chunks = mode == null + ? trimmedChunks + : _prepareChunksForSending(trimmedChunks, mode); + _recordedChunks.clear(); + + debugPrint( + '🎙️ [Voice] silence trim enabled=$trimSilenceEnabled: raw=${rawChunks.length} chunks -> kept=${chunks.length} chunks', + ); + + if (mounted) { + setState(() { + _isRecording = false; + _isSendingVoice = chunks.isNotEmpty && sessionId != null; + }); + } + + if (chunks.isEmpty || sessionId == null || mode == null || !mounted) { + if (mounted) { + setState(() { + _isSendingVoice = false; + _currentVoiceSessionId = null; + }); + } + return; + } + + final shouldContinue = await _confirmPublicChannelMediaSend('voice'); + if (!shouldContinue) { + if (mounted) { + setState(() { + _isSendingVoice = false; + _currentVoiceSessionId = null; + }); + } + return; + } + + try { + await _encodeAndSendAllPackets( + chunks: chunks, + sessionId: sessionId, + mode: mode, + ); + } catch (e, st) { + debugPrint('❌ [Voice] _encodeAndSendAllPackets threw: $e\n$st'); + } finally { + debugPrint( + '🎙️ [Voice] _stopAndSendVoice finally: resetting _isSendingVoice', + ); + if (mounted) { + setState(() { + _isSendingVoice = false; + _currentVoiceSessionId = null; + }); + } + } + } + + /// Stop the recorder and discard the captured audio without sending. + Future _cancelVoiceRecording() async { + if (!_isRecording) return; + debugPrint( + '🎙️ [Voice] _cancelVoiceRecording: discarding ${_recordedChunks.length} chunks', + ); + + await _voiceStreamSub?.cancel(); + _voiceStreamSub = null; + await _voiceRecorder.stopCapture(); + _recordedChunks.clear(); + _currentVoiceSessionId = null; + + if (!mounted) return; + setState(() { + _isRecording = false; + _isSendingVoice = false; + }); + ToastLogger.info(context, AppLocalizations.of(context)!.recordingDiscarded); + } + + Future _encodeAndSendAllPackets({ + required List chunks, + required String sessionId, + required VoicePacketMode mode, + }) async { + final total = chunks.length; + final codec = VoiceCodecService(); + final connectionProvider = context.read(); + final messagesProvider = context.read(); + final voiceProvider = context.read(); + final msgId = 'voice_${sessionId}_sent'; + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = + devicePublicKey != null && devicePublicKey.length >= 6 + ? devicePublicKey.sublist(0, 6) + : null; + final isChannel = + _destinationType == + MessageDestinationPreferences.destinationTypeChannel; + final recipient = _selectedRecipient; + final channelIdx = isChannel ? (recipient?.publicKey[1] ?? 0) : null; + + final encodedPackets = []; + debugPrint( + '🎙️ [Voice] encoding $total packets for deferred voice fetch, mode=${mode.label}, session=$sessionId', + ); + for (var i = 0; i < total; i++) { + if (!mounted) return; + try { + final codec2Data = await codec.encode(chunks[i], mode); + debugPrint( + '🎙️ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes', + ); + final packet = VoicePacket( + sessionId: sessionId, + mode: mode, + index: i, + total: total, + codec2Data: codec2Data, + ); + + encodedPackets.add(packet); + voiceProvider.addPacket(packet); + } catch (e, st) { + debugPrint('❌ [Voice] packet $i encode error: $e\n$st'); + } + } + + if (encodedPackets.isEmpty) { + debugPrint('❌ [Voice] No packets encoded for session $sessionId'); + messagesProvider.markMessageFailed(msgId); + return; + } + + voiceProvider.cacheOutgoingSession(sessionId, encodedPackets); + + if (senderPublicKeyPrefix == null || senderPublicKeyPrefix.length < 6) { + debugPrint('❌ [Voice] Missing device public key prefix for envelope'); + messagesProvider.markMessageFailed(msgId); + return; + } + + final durationMs = encodedPackets.fold( + 0, + (sum, p) => sum + p.durationMs, + ); + final envelope = VoiceEnvelope( + sessionId: sessionId, + mode: mode, + total: encodedPackets.length, + durationMs: durationMs, + version: 3, + ); + final envelopeText = envelope.encodeText(); + + // Insert placeholder with real VE1 envelope text so technical details are populated. + final sentMsg = Message( + id: msgId, + messageType: (!isChannel && recipient != null) + ? MessageType.contact + : MessageType.channel, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + text: envelopeText, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sent, + isVoice: true, + voiceId: sessionId, + channelIdx: channelIdx, + recipientPublicKey: isChannel ? null : recipient?.publicKey, + ); + messagesProvider.addSentMessage(sentMsg, contact: recipient); + + try { + if (isChannel) { + await connectionProvider.sendChannelMessage( + channelIdx: channelIdx ?? 0, + text: envelopeText, + messageId: msgId, + floodScopeKey: _channelRegionScopeKey, + ); + } else if (recipient != null) { + final sentSuccessfully = await connectionProvider.sendTextMessage( + contactPublicKey: recipient.publicKey, + text: envelopeText, + messageId: msgId, + contact: recipient, + ); + if (!sentSuccessfully) { + messagesProvider.markMessageFailed(msgId); + return; + } + } else { + messagesProvider.markMessageFailed(msgId); + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.noRecipientSelected, + ); + return; + } + } catch (e, st) { + debugPrint('❌ [Voice] envelope send error: $e\n$st'); + messagesProvider.markMessageFailed(msgId); + return; + } + + debugPrint('🎙️ [Voice] envelope sent for session $sessionId'); + if (isChannel) { + for (final packet in encodedPackets) { + await connectionProvider.sendChannelData( + channelIdx: channelIdx ?? 0, + dataType: MeshCoreConstants.dataTypeDev, + payload: packet.encodeBinary(), + floodScopeKey: _channelRegionScopeKey, + ); + } + } + // Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking). + // addSentMessage() forces deliveryStatus.sending; we upgrade it here so the + // bubble shows "Sent" instead of "Sending" once all packets are on the wire. + // For channels this is also set by the onMessageSent callback, but this is harmless. + messagesProvider.markMessageSent(msgId, 0, 0); + } + + List _prepareChunksForSending( + List chunks, + VoicePacketMode mode, + ) { + return chunks; + } + + List _trimSilence(List chunks) { + if (chunks.isEmpty) return chunks; + + final isSilent = chunks.map(_isSilentChunk).toList(); + final firstVoice = isSilent.indexWhere((silent) => !silent); + if (firstVoice == -1) return const []; + + final lastVoice = isSilent.lastIndexWhere((silent) => !silent); + if (lastVoice < firstVoice) return const []; + + final trimmed = []; + var interiorSilentRun = 0; + for (var i = firstVoice; i <= lastVoice; i++) { + if (isSilent[i]) { + interiorSilentRun++; + if (interiorSilentRun <= _maxInteriorSilentChunks) { + trimmed.add(chunks[i]); + } + } else { + interiorSilentRun = 0; + trimmed.add(chunks[i]); + } + } + return trimmed; + } + + bool _isSilentChunk(Int16List chunk) { + if (chunk.isEmpty) return true; + + final rms = _chunkRms(chunk); + final peak = _chunkPeak(chunk); + return rms < _silenceRmsThreshold && peak < _silencePeakThreshold; + } + + double _chunkRms(Int16List chunk) { + var sumSquares = 0.0; + for (final sample in chunk) { + sumSquares += sample * sample; + } + return math.sqrt(sumSquares / chunk.length); + } + + int _chunkPeak(Int16List chunk) { + var peak = 0; + for (final sample in chunk) { + final absSample = sample.abs(); + if (absSample > peak) { + peak = absSample; + } + } + return peak; + } + + bool _isPublicChannelSelected() { + if (_destinationType != + MessageDestinationPreferences.destinationTypeChannel) { + return false; + } + final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; + return channelIdx == 0; + } + + Future _confirmPublicChannelMediaSend(String mediaType) async { + if (!_isPublicChannelSelected() || !mounted) return true; + + final decision = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(context)!.sendToPublicChannel), + content: Text( + AppLocalizations.of(context)!.publicChannelMediaWarning( + mediaType == 'image' + ? AppLocalizations.of(context)!.image + : AppLocalizations.of(context)!.voice, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(AppLocalizations.of(context)!.sendAnyway), + ), + ], + ), + ); + + return decision ?? false; + } + + // ── SAR dialog ───────────────────────────────────────────────────────────── + + void _showSarDialog() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => SarUpdateSheet( + onSend: + ( + emoji, + name, + position, + roomPublicKey, + sendToChannel, + sendToAllContacts, + colorIndex, + ) async { + await _sendSarMessage( + emoji, + name, + position, + roomPublicKey, + sendToChannel, + sendToAllContacts, + colorIndex, + ); + }, + ), + ); + } + + Future _runAfterSheetDismissal( + BuildContext sheetContext, + Future Function() action, + ) async { + Navigator.pop(sheetContext); + await Future.delayed(const Duration(milliseconds: 180)); + if (!mounted) return; + await action(); + } + + int? _selectedPrivateChannelIdx() { + if (_destinationType != + MessageDestinationPreferences.destinationTypeChannel) { + return null; + } + final recipient = _selectedRecipient; + if (recipient == null || + recipient.isPublicChannel || + recipient.publicKey.length < 2) { + return null; + } + return recipient.publicKey[1]; + } + + String _locationSharingErrorMessage(Object error) { + final message = error.toString(); + if (message.startsWith('Bad state: ')) { + return message.substring('Bad state: '.length); + } + return message; + } + + Future _setSelectedChannelLocationSharing( + int channelIdx, + bool enabled, + ) async { + try { + final result = await context + .read() + .setChannelLocationSharingEnabled( + channelIdx, + enabled, + l10n: AppLocalizations.of(context), + ); + if (!mounted) return; + ToastLogger.success(context, result.message); + } catch (error) { + if (!mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + + Future _handleChannelLocationSharingAction() async { + final recipient = _selectedRecipient; + if (recipient == null || recipient.isPublicChannel) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.selectPrivateChannelToShareLocation, + ); + return; + } + + final channelIdx = recipient.publicKey.length > 1 + ? recipient.publicKey[1] + : 0; + if (channelIdx <= 0) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.selectPrivateChannelToShareLocation, + ); + return; + } + + try { + final sharingState = await context + .read() + .getChannelLocationSharingState(channelIdx); + if (!mounted) return; + await _setSelectedChannelLocationSharing( + channelIdx, + !sharingState.isSharing, + ); + } catch (error) { + if (!mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + + void _showComposerActions() { + final privateChannelIdx = _selectedPrivateChannelIdx(); + final locationSharingStateFuture = privateChannelIdx == null + ? null + : context.read().getChannelLocationSharingState( + privateChannelIdx, + ); + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (sheetContext) { + final theme = Theme.of(sheetContext); + final l10n = AppLocalizations.of(context)!; + + Future runAction(Future Function() action) async { + await _runAfterSheetDismissal(sheetContext, action); + } + + Widget buildActionsSheet(ChannelLocationSharingState? sharingState) { + final actions = [ + _ComposerActionTile( + icon: Icons.search_rounded, + title: l10n.searchMessages, + color: const Color(0xFF2B6CB0), + onTap: () => runAction(() async { + _showFilteredMessageSearch(); + }), + ), + _ComposerActionTile( + icon: Icons.campaign_rounded, + title: l10n.sendAdvert, + color: theme.colorScheme.primary, + onTap: () => runAction(() async { + await triggerAdvertFeedback(context); + if (!mounted) return; + final mode = await showAdvertModeSheet(context); + if (!mounted || mode == null) return; + await advertiseDevice( + context, + floodMode: mode == AdvertMode.flood, + ); + }), + ), + _ComposerActionTile( + icon: Icons.add_location_alt_rounded, + title: l10n.sendSarMarker, + color: const Color(0xFFB45309), + onTap: () => runAction(() async { + _showSarDialog(); + }), + ), + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) + _ComposerActionTile( + icon: sharingState?.isSharing == true + ? Icons.location_off_rounded + : Icons.share_location_rounded, + title: sharingState?.isSharing == true + ? l10n.stopSharingMyLocation + : l10n.shareMyLocation, + color: const Color(0xFF0F766E), + onTap: () => runAction(() async { + await _handleChannelLocationSharingAction(); + }), + ), + if (_voiceSupported) + _ComposerActionTile( + icon: _isRecording ? Icons.stop_rounded : Icons.mic_rounded, + title: _isRecording ? l10n.stopRecording : l10n.recordVoice, + color: const Color(0xFF7C3AED), + enabled: !_isSendingVoice, + busy: _isSendingVoice, + onTap: !_isSendingVoice + ? () => runAction(() async { + if (_isRecording) { + await _stopAndSendVoice(); + } else { + await _startVoiceRecording(); + } + }) + : null, + ), + _ComposerActionTile( + icon: Icons.photo_library_rounded, + title: l10n.sendImageFromGallery, + color: const Color(0xFF0F766E), + enabled: !_isSendingImage, + busy: _isSendingImage, + onTap: !_isSendingImage + ? () => runAction(() async { + await _pickAndSendImage(source: ImageSource.gallery); + }) + : null, + ), + _ComposerActionTile( + icon: Icons.camera_alt_rounded, + title: l10n.takePhoto, + color: const Color(0xFF2563EB), + enabled: !_isSendingImage, + busy: _isSendingImage, + onTap: !_isSendingImage + ? () => runAction(() async { + await _pickAndSendImage(source: ImageSource.camera); + }) + : null, + ), + _ComposerActionTile( + icon: Icons.grid_3x3_rounded, + title: l10n.startTictactoe, + color: const Color(0xFFBE185D), + onTap: () => runAction(() async { + await _startTicTacToeGame(); + }), + ), + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) + _ComposerActionTile( + icon: _channelRegionScopeName != null + ? Icons.language_rounded + : Icons.public_rounded, + title: _channelRegionScopeName != null + ? '${l10n.regionScope}: $_channelRegionScopeName' + : l10n.setRegionScope, + color: const Color(0xFF7C3AED), + onTap: () => runAction(() async { + _showRegionScopeSheet(); + }), + ), + ]; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(sheetContext).size.height * 0.72, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.moreActions, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 16), + for (var index = 0; index < actions.length; index++) ...[ + actions[index], + if (index != actions.length - 1) + const SizedBox(height: 8), + ], + ], + ), + ), + ), + ); + } + + if (locationSharingStateFuture == null) { + return buildActionsSheet(null); + } + + return FutureBuilder( + future: locationSharingStateFuture, + builder: (context, snapshot) { + return buildActionsSheet(snapshot.data); + }, + ); + }, + ); + } + + void _showRegionScopeSheet() { + final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; + final l10n = AppLocalizations.of(context)!; + + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (sheetContext) { + return _RegionScopeSheet( + currentScopeName: _channelRegionScopeName, + l10n: l10n, + onScopeSelected: (String? name) async { + Navigator.of(sheetContext).pop(); + if (name == null) { + // Clear scope + await RegionScopePreferences.clearScope(channelIdx); + if (!mounted) return; + setState(() { + _channelRegionScopeName = null; + _channelRegionScopeKey = null; + _channelRegionScopes.remove(channelIdx); + }); + ToastLogger.success(context, l10n.regionScopeCleared); + } else { + // Set scope + final key = RegionScopePreferences.deriveRegionKey(name); + await RegionScopePreferences.setScope(channelIdx, name, key); + if (!mounted) return; + setState(() { + _channelRegionScopeName = name; + _channelRegionScopeKey = key; + _channelRegionScopes[channelIdx] = name; + }); + ToastLogger.success(context, l10n.regionScopeSet(name)); + } + }, + ); + }, + ); + } + + void _showFilteredMessageSearch() { + final messagesProvider = context.read(); + final scopedMessages = _getFilteredMessages(messagesProvider); + final searchController = TextEditingController(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setModalState) { + final query = searchController.text.trim().toLowerCase(); + final matches = query.isEmpty + ? scopedMessages + : scopedMessages.where((message) { + return message.text.toLowerCase().contains(query) || + (message.senderName?.toLowerCase().contains(query) ?? + false); + }).toList(); + + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, + ), + child: SizedBox( + height: MediaQuery.of(sheetContext).size.height * 0.72, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: TextField( + controller: searchController, + autofocus: true, + decoration: InputDecoration( + hintText: AppLocalizations.of( + context, + )!.searchInCurrentFilter, + prefixIcon: const Icon(Icons.search), + suffixIcon: searchController.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + setModalState(() {}); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onChanged: (_) => setModalState(() {}), + ), + ), + Expanded( + child: matches.isEmpty + ? Center( + child: Text( + query.isEmpty + ? AppLocalizations.of( + context, + )!.noMessagesInFilter + : AppLocalizations.of( + context, + )!.noMatchesInFilter, + ), + ) + : ListView.separated( + itemCount: matches.length, + separatorBuilder: (_, _) => + const Divider(height: 1), + itemBuilder: (context, index) { + final message = matches[index]; + final title = + message.senderName?.trim().isNotEmpty == + true + ? message.senderName! + : message.isSentMessage + ? AppLocalizations.of(context)!.you + : _getDestinationLabel(); + + return ListTile( + title: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + message.text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + Navigator.pop(sheetContext); + _scrollToMessage(message.id); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ).whenComplete(searchController.dispose); + } + + Widget _buildDestinationAvatar(BuildContext context) { + final recipient = _selectedRecipient; + if (recipient != null) { + final sharingMode = + recipient.isChannel && + !recipient.isPublicChannel && + recipient.publicKey.length > 1 + ? context.watch().channelLocationSharingModeForChannel( + recipient.publicKey[1], + ) + : null; + return Stack( + clipBehavior: Clip.none, + children: [ + ContactAvatar( + contact: recipient, + radius: 14, + displayName: _getDestinationLabel(), + ), + if (sharingMode != null) + Positioned( + right: -3, + bottom: -3, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: const Color(0xFF16A34A), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + alignment: Alignment.center, + child: Icon(Icons.location_pin, size: 9, color: Colors.white), + ), + ), + ], + ); + } + + return Icon( + _getDestinationIcon(), + size: 17, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + } + + Future _sendSarMessage( + String emoji, + String name, + Position position, + Uint8List? roomPublicKey, + bool sendToChannel, + bool sendToAllContacts, + int colorIndex, + ) async { + final connectionProvider = context.read(); + final messagesProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.notConnectedToDevice, + ); + return; + } + + if (!sendToChannel && !sendToAllContacts && roomPublicKey == null) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.pleaseSelectADestinationToSendSarMarker, + ); + return; + } + + try { + // New format: S:::,: + // Round coordinates to 5 decimal places (~1m accuracy) since most GPS is only that accurate + final sarMessage = + 'S:$emoji:${colorIndex.toString()}:${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}:$name'; + + if (sendToAllContacts) { + // Send to all chat contacts (ContactType.chat); in simple mode only + // favourite contacts are reachable. + final contactsProvider = context.read(); + final chatContacts = context.read().isSimpleMode + ? contactsProvider.favouriteChatContacts + : contactsProvider.chatContacts; + + if (chatContacts.isEmpty) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.noContactsAvailable, + ); + return; + } + + // Create a single grouped message instead of multiple individual messages + final groupId = '${DateTime.now().millisecondsSinceEpoch}_group'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create recipient list + final recipients = chatContacts.map((contact) { + return MessageRecipient( + publicKey: contact.publicKey, + displayName: contact.displayName, + deliveryStatus: MessageDeliveryStatus.sending, + sentAt: DateTime.now(), + ); + }).toList(); + + // Create single grouped message + final groupedMessage = Message( + id: groupId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: sarMessage, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + groupId: groupId, + recipients: recipients, + ); + + // Add the grouped message to the list + messagesProvider.addSentMessage(groupedMessage); + + // Send to each contact and track status + int successCount = 0; + for (final contact in chatContacts) { + final individualMessageId = '${groupId}_${contact.publicKeyShort}'; + + // Register this individual send as part of the grouped message + messagesProvider.registerGroupedMessageSend( + individualMessageId, + groupId, + contact.publicKey, + ); + + // Send SAR message to contact (with ACK tracking) + final sentSuccessfully = await connectionProvider.sendTextMessage( + contactPublicKey: contact.publicKey, + text: sarMessage, + messageId: individualMessageId, + contact: contact, + ); + + if (sentSuccessfully) { + successCount++; + } else { + // Update recipient status in grouped message + messagesProvider.updateGroupedMessageRecipientStatus( + groupId, + contact.publicKey, + MessageDeliveryStatus.failed, + ); + } + + // Add 1 second delay between sends to ensure: + // 1. Different timestamps (messages sent in different seconds) + // 2. Radio has time to fully process previous message and assign ACK tag + // This ensures each message gets a unique ACK tag from the radio + if (contact != chatContacts.last) { + await Future.delayed(const Duration(seconds: 1)); + } + } + + if (!mounted) return; + ToastLogger.success( + context, + AppLocalizations.of(context)!.sarMarkerSentToContacts(successCount), + ); + } else if (sendToChannel) { + // Create message ID + final messageId = + '${DateTime.now().millisecondsSinceEpoch}_channel_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object + final sentMessage = Message( + id: messageId, + messageType: MessageType.channel, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: sarMessage, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + channelIdx: 0, + // SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider + ); + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage); + + // Send to public channel (ephemeral, over-the-air only) + await connectionProvider.sendChannelMessage( + channelIdx: 0, + text: sarMessage, + messageId: messageId, + floodScopeKey: _channelRegionScopeKey, + ); + + if (!mounted) return; + } else { + // Create message ID + final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object with recipient public key for retry support + final sentMessage = Message( + id: messageId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: sarMessage, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: roomPublicKey, // Store recipient for retry + // SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider + ); + + // Look up the room contact for path logging + final contactsProvider = context.read(); + final roomContact = contactsProvider.contacts.where((c) { + return c.publicKey.length >= roomPublicKey!.length && + c.publicKey.matches(roomPublicKey); + }).firstOrNull; + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage, contact: roomContact); + + // Send SAR message to selected room (persisted and immutable) + final sentSuccessfully = await connectionProvider.sendTextMessage( + contactPublicKey: roomPublicKey!, + text: sarMessage, + messageId: messageId, // Pass message ID so it can be tracked + contact: roomContact, // Include contact for path status logging + ); + + if (!sentSuccessfully) { + // Mark message as failed if sending failed + messagesProvider.markMessageFailed(messageId); + } + + if (!mounted) return; + ToastLogger.success( + context, + AppLocalizations.of(context)!.sarMarkerSentToRoom, + ); + } + } catch (e) { + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToSendSarMarker('$e'), + ); + } + } + + /// Handle pull-to-refresh for manual message sync + /// This is a FALLBACK mechanism - messages are normally synced automatically via PUSH_CODE_MSG_WAITING + Future _handleRefresh() async { + final connectionProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (!mounted) return; + ToastLogger.warning( + context, + AppLocalizations.of(context)!.notConnectedToDevice, + ); + return; + } + + try { + debugPrint( + '🔄 [MessagesTab] Manual refresh triggered - syncing messages', + ); + await connectionProvider.syncAllMessages(force: true); + if (!mounted) return; + } catch (e) { + debugPrint('❌ [MessagesTab] Sync error: $e'); + if (!mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.syncFailed('$e'), + ); + } + } + + void _markCurrentDestinationAsRead() { + _channelReadTimer?.cancel(); + _pendingChannelReadKey = null; + context.read().markDestinationAsRead( + destinationType: _destinationType, + contact: _selectedRecipient, + ); + } + + void _syncChannelAutoReadTimer(MessagesProvider messagesProvider) { + if (!widget.isActive || + _destinationType != + MessageDestinationPreferences.destinationTypeChannel) { + _channelReadTimer?.cancel(); + _pendingChannelReadKey = null; + return; + } + + final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; + final unreadCount = messagesProvider.getUnreadCountForChannel(channelIdx); + + if (unreadCount <= 0) { + _channelReadTimer?.cancel(); + _pendingChannelReadKey = null; + return; + } + + final nextKey = '$channelIdx:$unreadCount'; + if (_pendingChannelReadKey == nextKey && + _channelReadTimer?.isActive == true) { + return; + } + + _channelReadTimer?.cancel(); + _pendingChannelReadKey = nextKey; + _channelReadTimer = Timer(_channelAutoReadDelay, () { + if (!mounted || !widget.isActive) { + return; + } + + if (_destinationType != + MessageDestinationPreferences.destinationTypeChannel) { + return; + } + + final currentChannelIdx = _selectedRecipient?.publicKey[1] ?? 0; + if (currentChannelIdx != channelIdx) { + return; + } + + final latestProvider = context.read(); + if (latestProvider.getUnreadCountForChannel(channelIdx) <= 0) { + return; + } + + _markCurrentDestinationAsRead(); + }); + } + + List _getFilteredMessages(MessagesProvider messagesProvider) { + // Get all recent messages + final allMessages = messagesProvider.getRecentMessages(count: 100); + + List filteredMessages; + + // If channel destination is selected, filter by selected channel. + if (_destinationType == MessageDestinationPreferences.destinationTypeAll) { + filteredMessages = allMessages; + } else if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) { + final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0; + filteredMessages = allMessages + .where( + (message) => + message.isChannelMessage && + (message.channelIdx ?? 0) == selectedChannelIdx, + ) + .toList(); + } + // If a contact or room is selected, filter by recipient/sender prefixes. + else if ((_destinationType == + MessageDestinationPreferences.destinationTypeContact || + _destinationType == + MessageDestinationPreferences.destinationTypeRoom) && + _selectedRecipient != null) { + filteredMessages = allMessages.where((message) { + if (!message.isContactMessage) { + return false; + } + + // Include messages sent TO this recipient + if (message.recipientPublicKey != null && + message.recipientPublicKey!.length >= 6 && + _selectedRecipient!.publicKey.length >= 6) { + // Compare first 6 bytes (public key prefix) + final recipientPrefix = message.recipientPublicKey!.sublist(0, 6); + final selectedPrefix = _selectedRecipient!.publicKey.sublist(0, 6); + if (recipientPrefix.matches(selectedPrefix)) { + return true; + } + } + + // Include messages received FROM this recipient + if (message.senderPublicKeyPrefix != null && + message.senderPublicKeyPrefix!.length >= 6 && + _selectedRecipient!.publicKey.length >= 6) { + final senderPrefix = message.senderPublicKeyPrefix!.sublist(0, 6); + final selectedPrefix = _selectedRecipient!.publicKey.sublist(0, 6); + if (senderPrefix.matches(selectedPrefix)) { + return true; + } + } + + return false; + }).toList(); + } else { + // Default: show all messages (fallback case) + filteredMessages = allMessages; + } + + return filteredMessages + .where((message) => !message.isSystemMessage) + .toList(); + } + + void _handleMessageTap(Message message) { + if (widget.onNavigateToMap == null) return; + + if (message.isSarMarker) { + final mapProvider = context.read(); + final marker = message.toSarMarker(); + if (marker == null) { + return; + } + final error = mapProvider.navigateToSarMarker(marker); + if (error != null && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + return; + } + widget.onNavigateToMap?.call(); + return; + } + + if (message.isDrawing && message.drawingId != null) { + debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}'); + final mapProvider = context.read(); + final drawingProvider = context.read(); + final error = mapProvider.navigateToDrawing( + message.drawingId!, + drawingProvider, + ); + if (error != null && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + return; + } + widget.onNavigateToMap?.call(); + } + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, messagesProvider, child) { + _syncChannelAutoReadTimer(messagesProvider); + final messages = messagesProvider.buildDisplayMessages( + _getFilteredMessages(messagesProvider), + ); + final bottomInset = MediaQuery.of(context).viewPadding.bottom; + final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0; + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).unfocus(), + child: Stack( + children: [ + Positioned.fill( + child: MessagesContent( + messages: messages, + scrollController: _scrollController, + highlightedMessageId: _highlightedMessageId, + bottomContentPadding: + _composerOverlayHeight + composerBottomPadding, + onRefresh: _handleRefresh, + onNavigateToMap: widget.onNavigateToMap, + onMessageTap: _handleMessageTap, + onReplyToMessage: _replyToMessage, + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: MessagesComposer( + textController: _textController, + focusNode: _focusNode, + messageByteLimiter: _messageByteLimiter, + messageByteCount: _messageByteCount, + maxMessageBytes: _maxMessageBytes, + isRecording: _isRecording, + isSendingVoice: _isSendingVoice, + voiceSupported: _voiceSupported, + bottomPadding: composerBottomPadding, + destinationLabel: _getDestinationLabel(), + destinationAvatar: _buildDestinationAvatar(context), + destinationLocked: _isDestinationLocked, + mentionSuggestions: _mentionSuggestions, + mentionQuery: _mentionQuery, + onMentionSelected: _selectMention, + onShowComposerActions: _showComposerActions, + onShowRecipientSelector: _showRecipientSelector, + onStartVoiceRecording: _startVoiceRecording, + onStopAndSendVoice: _stopAndSendVoice, + onCancelVoiceRecording: _cancelVoiceRecording, + onSendMessage: _sendMessage, + onLongPressSend: _isContactDestination() + ? _showSendModeSheet + : null, + regionScopeName: _channelRegionScopeName, + onRegionScopeTap: _channelRegionScopeName != null + ? _showRegionScopeSheet + : null, + ), + ), + ], + ), + ); + }, + ); + } +} + +/// Bottom sheet for selecting a region scope for the current channel. +class _RegionScopeSheet extends StatefulWidget { + final String? currentScopeName; + final AppLocalizations l10n; + final ValueChanged onScopeSelected; + + const _RegionScopeSheet({ + required this.currentScopeName, + required this.l10n, + required this.onScopeSelected, + }); + + @override + State<_RegionScopeSheet> createState() => _RegionScopeSheetState(); +} + +class _RegionScopeSheetState extends State<_RegionScopeSheet> { + final TextEditingController _nameController = TextEditingController(); + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + void _submitManualName() { + var name = _nameController.text.trim(); + if (name.isEmpty) return; + if (!name.startsWith('#')) name = '#$name'; + if (utf8.encode(name).length > 30) return; + widget.onScopeSelected(name); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = widget.l10n; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.72, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.regionScope, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + l10n.regionScopeWarning, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + + _RegionOptionTile( + label: l10n.regionScopeNone, + isSelected: widget.currentScopeName == null, + onTap: () => widget.onScopeSelected(null), + ), + const SizedBox(height: 8), + + Row( + children: [ + Expanded( + child: TextField( + controller: _nameController, + decoration: InputDecoration( + hintText: l10n.enterRegionName, + isDense: true, + prefixText: '#', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + onSubmitted: (_) => _submitManualName(), + ), + ), + const SizedBox(width: 8), + FilledButton.tonal( + onPressed: _submitManualName, + child: const Icon(Icons.check_rounded, size: 20), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _RegionOptionTile extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _RegionOptionTile({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + isSelected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_off_rounded, + size: 20, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/offline_map_screen.dart b/lib/screens/offline_map_screen.dart new file mode 100644 index 0000000..d7eac71 --- /dev/null +++ b/lib/screens/offline_map_screen.dart @@ -0,0 +1,846 @@ +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 createState() => _OfflineMapScreenState(); +} + +class _OfflineMapScreenState extends State { + final MapController _mapController = MapController(); + late OfflineTilesProvider _provider; + double _currentZoom = 8; + + @override + void initState() { + super.initState(); + _provider = context.read(); + _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( + 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( + builder: (context, provider, _) { + return PopupMenuButton( + 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( + 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, + tileProvider: NetworkTileProvider( + headers: provider.selectedLayer.headers ?? const {}, + ), + 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( + 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( + 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( + 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 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, + ), + ), + ], + ); + } +} diff --git a/lib/screens/packet_log_screen.dart b/lib/screens/packet_log_screen.dart new file mode 100644 index 0000000..c331ed2 --- /dev/null +++ b/lib/screens/packet_log_screen.dart @@ -0,0 +1,1110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:meshcore_client/meshcore_client.dart'; +import '../l10n/app_localizations.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../services/mesh_map_nodes_service.dart'; +import '../services/route_hash_preferences.dart'; +import '../utils/log_rx_route_decoder.dart'; + +class PacketLogScreen extends StatefulWidget { + final MeshCoreBleService bleService; + + const PacketLogScreen({super.key, required this.bleService}); + + @override + State createState() => _PacketLogScreenState(); +} + +class _PacketLogScreenState extends State { + bool _autoScroll = true; + final ScrollController _scrollController = ScrollController(); + String _searchQuery = ''; + PacketDirection? _filterDirection; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + List get _filteredLogs { + var logs = widget.bleService.packetLogs; + + // Filter by direction + if (_filterDirection != null) { + logs = logs.where((log) => log.direction == _filterDirection).toList(); + } + + // Filter by search query + if (_searchQuery.isNotEmpty) { + final query = _searchQuery.toLowerCase(); + logs = logs.where((log) { + return log.hexData.toLowerCase().contains(query) || + (log.description?.toLowerCase().contains(query) ?? false) || + log.summary.toLowerCase().contains(query); + }).toList(); + } + + return logs; + } + + Future _exportLogs(BuildContext context) async { + try { + final logs = _filteredLogs; + if (logs.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.noLogsToExport))); + } + return; + } + + // Create CSV content + final buffer = StringBuffer(); + buffer.writeln( + 'Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description', + ); + for (final log in logs) { + buffer.writeln(log.toCsvRow()); + } + + // Save to temporary file + final tempDir = await getTemporaryDirectory(); + if (!context.mounted) return; + final file = File( + '${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv', + ); + await file.writeAsString(buffer.toString()); + + // Share the file + if (!context.mounted) return; + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: 'MeshCore BLE Packet Logs', + text: 'Exported ${logs.length} BLE packets from MeshCore SAR app', + ), + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.exportFailed(e.toString())))); + } + } + } + + Future _exportAsText(BuildContext context) async { + try { + final logs = _filteredLogs; + if (logs.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.noLogsToExport))); + } + return; + } + + // Create text content + final buffer = StringBuffer(); + buffer.writeln('MeshCore BLE Packet Logs'); + buffer.writeln('=' * 80); + buffer.writeln('Exported: ${DateTime.now().toIso8601String()}'); + buffer.writeln('Total packets: ${logs.length}'); + buffer.writeln('=' * 80); + buffer.writeln(); + + for (final log in logs) { + buffer.writeln(log.toLogString()); + } + + // Save to temporary file + final tempDir = await getTemporaryDirectory(); + if (!context.mounted) return; + final file = File( + '${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt', + ); + await file.writeAsString(buffer.toString()); + + // Share the file + if (!context.mounted) return; + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: 'MeshCore BLE Packet Logs', + text: 'Exported ${logs.length} BLE packets from MeshCore SAR app', + ), + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.exportFailed(e.toString())))); + } + } + } + + void _copyToClipboard(BuildContext context, BlePacketLog log) { + Clipboard.setData(ClipboardData(text: log.hexData)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.hexDataCopiedToClipboard), + duration: Duration(seconds: 1), + ), + ); + } + + void _clearLogs(BuildContext context) { + final parentContext = context; // Store parent context for setState + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(dialogContext)!.clearAllData), + content: const Text( + 'Are you sure you want to clear all packet logs? This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + TextButton( + onPressed: () { + widget.bleService.clearPacketLogs(); + Navigator.pop(dialogContext); + if (!mounted) return; + setState(() {}); + if (parentContext.mounted) { + ScaffoldMessenger.of(parentContext).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.packetLogsCleared)), + ); + } + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(dialogContext)!.clear), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final logs = _filteredLogs; + + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AppLocalizations.of(context)!.blePacketLogs), + Text( + '${logs.length} packets', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + actions: [ + // Direction filter + PopupMenuButton( + icon: Icon( + _filterDirection == null + ? Icons.filter_list + : _filterDirection == PacketDirection.rx + ? Icons.arrow_downward + : Icons.arrow_upward, + ), + tooltip: AppLocalizations.of(context)!.filterByDirection, + onSelected: (direction) { + setState(() { + _filterDirection = direction; + }); + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: null, + child: Row( + children: [ + Icon( + Icons.filter_list, + color: _filterDirection == null + ? Theme.of(context).colorScheme.primary + : null, + ), + const SizedBox(width: 8), + Text( + 'All', + style: TextStyle( + fontWeight: _filterDirection == null + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ], + ), + ), + PopupMenuItem( + value: PacketDirection.rx, + child: Row( + children: [ + Icon( + Icons.arrow_downward, + color: _filterDirection == PacketDirection.rx + ? Theme.of(context).colorScheme.primary + : null, + ), + const SizedBox(width: 8), + Text( + 'RX (Received)', + style: TextStyle( + fontWeight: _filterDirection == PacketDirection.rx + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ], + ), + ), + PopupMenuItem( + value: PacketDirection.tx, + child: Row( + children: [ + Icon( + Icons.arrow_upward, + color: _filterDirection == PacketDirection.tx + ? Theme.of(context).colorScheme.primary + : null, + ), + const SizedBox(width: 8), + Text( + 'TX (Sent)', + style: TextStyle( + fontWeight: _filterDirection == PacketDirection.tx + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ], + ), + ), + ], + ), + // Auto-scroll toggle + IconButton( + icon: Icon( + _autoScroll + ? Icons.vertical_align_bottom + : Icons.vertical_align_center, + ), + tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll', + onPressed: () { + setState(() { + _autoScroll = !_autoScroll; + }); + }, + ), + // Export menu + PopupMenuButton( + icon: const Icon(Icons.share), + tooltip: AppLocalizations.of(context)!.exportLogs, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'csv', + child: Row( + children: [ + const Icon(Icons.table_chart), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.exportAsCsv), + ], + ), + ), + PopupMenuItem( + value: 'txt', + child: Row( + children: [ + const Icon(Icons.text_snippet), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.exportAsText), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'csv') { + _exportLogs(context); + } else if (value == 'txt') { + _exportAsText(context); + } + }, + ), + // Clear logs + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: AppLocalizations.of(context)!.clearLogs, + onPressed: () => _clearLogs(context), + ), + ], + ), + body: Column( + children: [ + // Search bar + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.searchLogs, + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + ), + // Logs list + Expanded( + child: logs.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.list_alt, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + _searchQuery.isNotEmpty || _filterDirection != null + ? 'No matching packets found' + : 'No packets logged yet', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + if (_searchQuery.isNotEmpty || + _filterDirection != null) ...[ + const SizedBox(height: 8), + TextButton.icon( + onPressed: () { + setState(() { + _searchQuery = ''; + _filterDirection = null; + }); + }, + icon: Icon(Icons.clear_all), + label: Text(AppLocalizations.of(context)!.clearFilters), + ), + ], + ], + ), + ) + : ListView.builder( + controller: _scrollController, + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + + // Auto-scroll to bottom + if (_autoScroll && index == logs.length - 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + + return _PacketLogCard( + log: log, + onCopy: () => _copyToClipboard(context, log), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _PacketLogCard extends StatelessWidget { + final BlePacketLog log; + final VoidCallback onCopy; + + const _PacketLogCard({required this.log, required this.onCopy}); + + @override + Widget build(BuildContext context) { + final isRx = log.direction == PacketDirection.rx; + final directionColor = isRx ? Colors.green : Colors.blue; + final rxInfo = log.logRxDataInfo; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: ExpansionTile( + leading: CircleAvatar( + backgroundColor: directionColor.withValues(alpha: 0.2), + child: Icon( + isRx ? Icons.arrow_downward : Icons.arrow_upward, + color: directionColor, + size: 20, + ), + ), + title: Row( + children: [ + Text( + isRx ? 'RX' : 'TX', + style: TextStyle( + fontWeight: FontWeight.bold, + color: directionColor, + fontSize: 12, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + log.responseCode != null ? log.opcodeName : 'N/A', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + '${log.rawData.length} bytes • ${_formatTimestamp(log.timestamp)}', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _FactCard( + icon: isRx ? Icons.call_received : Icons.call_made, + label: AppLocalizations.of(context)!.direction, + value: isRx ? 'RX' : 'TX', + accent: directionColor, + ), + _FactCard( + icon: Icons.data_object, + label: AppLocalizations.of(context)!.size, + value: '${log.rawData.length} bytes', + ), + _FactCard( + icon: Icons.schedule, + label: AppLocalizations.of(context)!.captured, + value: _formatTimestamp(log.timestamp), + ), + if (log.responseCode != null) + _FactCard( + icon: Icons.sell, + label: AppLocalizations.of(context)!.opcode, + value: log.opcodeName, + ), + ], + ), + if (rxInfo?.rssiDbm != null || rxInfo?.snrDb != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Link Quality', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + const SizedBox(height: 10), + if (rxInfo?.rssiDbm != null) + _SignalMeter( + label: AppLocalizations.of(context)!.rssi, + valueLabel: '${rxInfo!.rssiDbm} dBm', + normalized: _normalizeRssi( + rxInfo.rssiDbm!.toDouble(), + ), + color: _rssiColor(rxInfo.rssiDbm!.toDouble()), + ), + if (rxInfo?.snrDb != null) ...[ + SizedBox(height: 8), + _SignalMeter( + label: AppLocalizations.of(context)!.snr, + valueLabel: + '${rxInfo!.snrDb!.toStringAsFixed(1)} dB', + normalized: _normalizeSnr(rxInfo.snrDb!), + color: _snrColor(rxInfo.snrDb!), + ), + ], + ], + ), + ), + ], + if (isRx) ...[ + const SizedBox(height: 12), + _DecodedRouteSection(log: log), + ], + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of( + context, + ).dividerColor.withValues(alpha: 0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.grid_view_rounded, size: 16), + const SizedBox(width: 6), + const Text( + 'Hex Explorer', + style: TextStyle(fontWeight: FontWeight.w700), + ), + const Spacer(), + IconButton( + onPressed: onCopy, + tooltip: AppLocalizations.of(context)!.copyFullHex, + icon: const Icon(Icons.copy_all_rounded, size: 18), + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (var i = 0; i < log.rawData.length; i++) + _HexByteChip( + index: i, + value: log.rawData[i], + onTap: () { + _copyText( + context, + log.rawData[i] + .toRadixString(16) + .padLeft(2, '0') + .toUpperCase(), + 'Byte ${i.toString().padLeft(2, '0')} copied', + ); + }, + ), + ], + ), + const SizedBox(height: 8), + ExpansionTile( + tilePadding: EdgeInsets.zero, + dense: true, + visualDensity: VisualDensity.compact, + title: const Text( + 'Raw stream', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + log.hexData, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + String _formatTimestamp(DateTime timestamp) { + final now = DateTime.now(); + final diff = now.difference(timestamp); + + if (diff.inSeconds < 60) { + return '${diff.inSeconds}s ago'; + } else if (diff.inMinutes < 60) { + return '${diff.inMinutes}m ago'; + } else if (diff.inHours < 24) { + return '${diff.inHours}h ago'; + } else { + return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}'; + } + } + + static void _copyText(BuildContext context, String text, String message) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(milliseconds: 900), + ), + ); + } + + static double _normalizeRssi(double rssi) { + return ((rssi + 120.0) / 70.0).clamp(0.0, 1.0); + } + + static double _normalizeSnr(double snr) { + return ((snr + 20.0) / 40.0).clamp(0.0, 1.0); + } + + static Color _rssiColor(double rssi) { + if (rssi >= -80) return Colors.green; + if (rssi >= -95) return Colors.amber; + return Colors.redAccent; + } + + static Color _snrColor(double snr) { + if (snr >= 10) return Colors.green; + if (snr >= 0) return Colors.amber; + return Colors.redAccent; + } +} + +class _DecodedRouteSection extends StatelessWidget { + final BlePacketLog log; + + const _DecodedRouteSection({required this.log}); + + @override + Widget build(BuildContext context) { + final contacts = context.watch().contacts; + final connectionProvider = context.watch(); + final ownPublicKey = connectionProvider.deviceInfo.publicKey; + final ownName = + connectionProvider.deviceInfo.selfName ?? + connectionProvider.deviceInfo.displayName; + + return FutureBuilder>( + future: Future.wait([ + RouteHashPreferences.getHashSize(), + MeshMapNodesService.loadCachedNodes(), + ]), + builder: (context, snapshot) { + final decodedRoute = LogRxRouteDecoder.decode( + log.rawData, + preferredHashSize: snapshot.data?.first as int?, + ); + if (decodedRoute == null) { + return const SizedBox.shrink(); + } + + final cachedNodes = snapshot.hasData + ? snapshot.data![1] as List + : const []; + final resolvedPath = decodedRoute.hopHashes + .map( + (hashHex) => _resolveHashWithFallback( + hashHex, + contacts: contacts, + cachedNodes: cachedNodes, + ownPublicKey: ownPublicKey, + ownName: ownName, + ), + ) + .toList(); + return _RouteSection(route: decodedRoute, path: resolvedPath); + }, + ); + } + + ResolvedNodeHash _resolveHashWithFallback( + String hashHex, { + required List contacts, + required List cachedNodes, + required Uint8List? ownPublicKey, + required String? ownName, + }) { + final localResolved = LogRxRouteDecoder.resolveHash( + hashHex, + contacts: contacts, + ownPublicKey: ownPublicKey, + ownName: ownName, + ); + if (localResolved.matchCount > 0 || localResolved.isOwnNode) { + return localResolved; + } + + final normalizedHashHex = hashHex.toLowerCase(); + final matches = cachedNodes + .where((node) => node.publicKey.startsWith(normalizedHashHex)) + .toList(); + if (matches.isEmpty) { + return localResolved; + } + + matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); + return ResolvedNodeHash( + hashHex: normalizedHashHex, + label: matches.first.name, + isOwnNode: false, + isUniqueMatch: matches.length == 1, + matchCount: matches.length, + ); + } +} + +class _RouteSection extends StatelessWidget { + final DecodedLogRxRoute route; + final List path; + + const _RouteSection({required this.route, required this.path}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.alt_route, size: 16), + SizedBox(width: 6), + Text( + 'Mesh Route', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _FactCard( + icon: Icons.route, + label: AppLocalizations.of(context)!.payload, + value: _payloadTypeLabel(route.payloadType), + ), + _FactCard( + icon: Icons.hub, + label: AppLocalizations.of(context)!.hops, + value: '${route.hopCount}', + ), + _FactCard( + icon: Icons.tag, + label: AppLocalizations.of(context)!.hashSize, + value: + '${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}', + ), + ], + ), + const SizedBox(height: 12), + if (path.isEmpty) + Text( + 'Direct packet, no hop path attached.', + style: TextStyle(fontSize: 12, color: Colors.grey[700]), + ) + else + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (var i = 0; i < path.length; i++) ...[ + _RouteHopChip(index: i + 1, node: path[i]), + if (i < path.length - 1) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: Icon(Icons.arrow_right_alt, size: 16), + ), + ], + ], + ), + ], + ), + ); + } + + static String _payloadTypeLabel(int payloadType) { + switch (payloadType) { + case 0x00: + return 'REQ'; + case 0x01: + return 'RESP'; + case 0x02: + return 'TXT'; + case 0x03: + return 'ACK'; + case 0x04: + return 'ADVERT'; + case 0x05: + return 'GRP_TXT'; + case 0x06: + return 'GRP_DATA'; + case 0x07: + return 'ANON_REQ'; + case 0x08: + return 'PATH'; + case 0x09: + return 'TRACE'; + case 0x0A: + return 'MULTIPART'; + case 0x0B: + return 'CONTROL'; + default: + return '0x${payloadType.toRadixString(16).padLeft(2, '0')}'; + } + } + + static String _nodeLabel(ResolvedNodeHash node) { + if (node.isOwnNode) { + return '${node.label} (${node.hexLabel})'; + } + if (node.matchCount == 0) { + return node.hexLabel; + } + if (node.isUniqueMatch) { + return '${node.label} (${node.hexLabel})'; + } + return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)'; + } +} + +class _RouteHopChip extends StatelessWidget { + final int index; + final ResolvedNodeHash node; + + const _RouteHopChip({required this.index, required this.node}); + + @override + Widget build(BuildContext context) { + final color = node.isOwnNode + ? Colors.blue + : node.isUniqueMatch + ? Colors.green + : Colors.orange; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Text( + '$index. ${_RouteSection._nodeLabel(node)}', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ); + } +} + +class _FactCard extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color? accent; + + const _FactCard({ + required this.icon, + required this.label, + required this.value, + this.accent, + }); + + @override + Widget build(BuildContext context) { + final tileColor = accent ?? Theme.of(context).colorScheme.primary; + return Container( + constraints: const BoxConstraints(minWidth: 108), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: tileColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: tileColor), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle(fontSize: 10, color: Colors.grey[700]), + ), + Text( + value, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _SignalMeter extends StatelessWidget { + final String label; + final String valueLabel; + final double normalized; + final Color color; + + const _SignalMeter({ + required this.label, + required this.valueLabel, + required this.normalized, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: 42, + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12), + ), + ), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: LinearProgressIndicator( + minHeight: 8, + value: normalized, + backgroundColor: color.withValues(alpha: 0.15), + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 74, + child: Text( + valueLabel, + textAlign: TextAlign.right, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ], + ); + } +} + +class _HexByteChip extends StatelessWidget { + final int index; + final int value; + final VoidCallback onTap; + + const _HexByteChip({ + required this.index, + required this.value, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final text = value.toRadixString(16).padLeft(2, '0').toUpperCase(); + return Tooltip( + message: 'Byte $index', + waitDuration: const Duration(milliseconds: 250), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(7), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 5), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(7), + ), + child: Text( + text, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/profiles_screen.dart b/lib/screens/profiles_screen.dart new file mode 100644 index 0000000..9e7183e --- /dev/null +++ b/lib/screens/profiles_screen.dart @@ -0,0 +1,310 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/config_profile.dart'; +import '../services/profile_manager.dart'; +import '../services/profile_workspace_coordinator.dart'; +import '../l10n/app_localizations.dart'; + +class ProfilesScreen extends StatelessWidget { + const ProfilesScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, profileManager, child) { + final profiles = profileManager.visibleProfiles; + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.profiles), + actions: [ + IconButton( + onPressed: () => _importProfile(context), + icon: const Icon(Icons.file_open), + tooltip: AppLocalizations.of(context)!.importProfile, + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _createProfile(context), + icon: Icon(Icons.add), + label: Text(AppLocalizations.of(context)!.newProfile), + ), + body: profiles.isEmpty + ? Center( + child: Text(AppLocalizations.of(context)!.enableProfilesToStartManagingThem), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: profiles.length, + itemBuilder: (context, index) { + final profile = profiles[index]; + final isActive = + profileManager.activeProfileId == profile.id; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + profile.name, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + ), + if (isActive) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text(AppLocalizations.of(context)!.active), + ), + ], + ), + const SizedBox(height: 8), + Text(_summary(profile)), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton( + onPressed: () async { + await context + .read() + .openProfile(profile.id); + }, + child: Text(AppLocalizations.of(context)!.open), + ), + OutlinedButton( + onPressed: () async { + final resolved = + profile.id == + ConfigProfile.defaultProfileId + ? await context + .read< + ProfileWorkspaceCoordinator + >() + .snapshotCurrentProfile( + id: profile.id, + name: profile.name, + ) + : profile; + if (!context.mounted) return; + await context + .read() + .exportProfile(resolved); + }, + child: Text(AppLocalizations.of(context)!.share), + ), + PopupMenuButton( + onSelected: (value) async { + switch (value) { + case 'duplicate': + await context + .read() + .duplicateProfile(profile); + break; + case 'rename': + await _renameProfile(context, profile); + break; + case 'delete': + await _deleteProfile( + context, + profile, + isActive: isActive, + ); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'duplicate', + child: Text(AppLocalizations.of(context)!.duplicate), + ), + if (!profile.isDefault) + PopupMenuItem( + value: 'rename', + child: Text(AppLocalizations.of(context)!.rename), + ), + if (!profile.isDefault) + PopupMenuItem( + value: 'delete', + child: Text(AppLocalizations.of(context)!.delete), + ), + ], + ), + ], + ), + ], + ), + ), + ); + }, + ), + ); + }, + ); + } + + String _summary(ConfigProfile profile) { + if (profile.isDefault) { + return 'Current app state and history.'; + } + + final sections = []; + if (profile.sections.deviceConfig?.isEmpty == false) { + sections.add('Device'); + } + if (profile.sections.channels.isNotEmpty) { + sections.add('${profile.sections.channels.length} channels'); + } + if (profile.sections.appSettings?.isEmpty == false) { + sections.add('App settings'); + } + if (profile.sections.mapWorkspace?.isEmpty == false) { + sections.add('Map workspace'); + } + return sections.isEmpty ? 'Empty profile' : sections.join(' | '); + } + + Future _importProfile(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final errorColor = Theme.of(context).colorScheme.error; + final coordinator = context.read(); + try { + final imported = await coordinator.importProfileFromFile(); + if (imported == null) { + messenger.showSnackBar( + SnackBar( + content: const Text('Could not import profile — invalid file'), + backgroundColor: errorColor, + ), + ); + return; + } + messenger.showSnackBar( + SnackBar(content: Text('Imported profile "${imported.name}"')), + ); + } catch (_) { + messenger.showSnackBar( + SnackBar( + content: const Text('Could not import profile — invalid file'), + backgroundColor: errorColor, + ), + ); + } + } + + Future _deleteProfile( + BuildContext context, + ConfigProfile profile, { + required bool isActive, + }) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Profile'), + content: Text( + 'Delete "${profile.name}"? Its messages and contacts will be ' + 'permanently erased.' + '${isActive ? '\n\nThis profile is currently active. The app will switch to the Default profile.' : ''}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.delete), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) { + return; + } + await context.read().deleteProfile(profile); + } + + Future _createProfile(BuildContext context) async { + final controller = TextEditingController(); + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.createProfile), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Profile name'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text.trim()), + child: Text(AppLocalizations.of(context)!.create), + ), + ], + ), + ); + if (name == null || name.isEmpty || !context.mounted) { + return; + } + await context.read().createProfileFromCurrent( + name: name, + ); + } + + Future _renameProfile( + BuildContext context, + ConfigProfile profile, + ) async { + final controller = TextEditingController(text: profile.name); + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.renameProfile), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Profile name'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text.trim()), + child: Text(AppLocalizations.of(context)!.save), + ), + ], + ), + ); + if (name == null || name.isEmpty || !context.mounted) { + return; + } + await context.read().renameProfile( + profile, + name, + ); + } +} diff --git a/lib/screens/repeaters_map_screen.dart b/lib/screens/repeaters_map_screen.dart new file mode 100644 index 0000000..38fbf8d --- /dev/null +++ b/lib/screens/repeaters_map_screen.dart @@ -0,0 +1,776 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../models/contact.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../services/mesh_map_nodes_service.dart'; +import '../l10n/app_localizations.dart'; + +class RepeatersMapScreen extends StatefulWidget { + const RepeatersMapScreen({super.key}); + + @override + State createState() => _RepeatersMapScreenState(); +} + +class _RepeatersMapScreenState extends State { + static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058); + static const double _fallbackZoom = 7; + static const double _myLocationZoom = 14; + + final flutter_map.MapController _mapController = flutter_map.MapController(); + + List _onlineRepeaters = const []; + bool _isLoading = true; + bool _didFitCamera = false; + String? _error; + double _currentZoom = _fallbackZoom; + LatLng? _myLocation; + final Set _addingRepeaters = {}; + + @override + void initState() { + super.initState(); + _loadRepeaters(); + } + + Future _loadRepeaters({bool forceRefresh = false}) async { + setState(() { + _isLoading = true; + _error = null; + if (forceRefresh) { + _didFitCamera = false; + } + }); + + try { + final nodes = await MeshMapNodesService.fetchNodes( + forceRefresh: forceRefresh, + ); + final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList(); + + if (!mounted) return; + setState(() { + _onlineRepeaters = repeaters; + _isLoading = false; + }); + _refreshMyLocation(); + } catch (error) { + final cached = await MeshMapNodesService.loadCachedNodes(); + final repeaters = cached.where(MeshMapNodesService.isRepeater).toList(); + + if (!mounted) return; + setState(() { + _onlineRepeaters = repeaters; + _isLoading = false; + _error = repeaters.isEmpty + ? 'Unable to load repeaters right now.' + : 'Showing cached repeaters. Pull to retry.'; + }); + _refreshMyLocation(); + } + } + + List<_MapRepeater> _mergeRepeaters({ + required List onlineNodes, + required List contactRepeaters, + }) { + final merged = {}; + + for (final node in onlineNodes) { + merged[node.publicKey] = _MapRepeater( + publicKey: node.publicKey, + name: node.name, + latitude: node.latitude, + longitude: node.longitude, + isFromContacts: false, + isFromOnline: true, + ); + } + + for (final contact in contactRepeaters) { + final location = contact.displayLocation; + if (location == null) { + continue; + } + + final key = contact.publicKeyHex.toLowerCase(); + final existing = merged[key]; + merged[key] = _MapRepeater( + publicKey: key, + name: contact.displayName.isNotEmpty + ? contact.displayName + : existing?.name ?? 'Unknown repeater', + latitude: existing?.latitude ?? location.latitude, + longitude: existing?.longitude ?? location.longitude, + isFromContacts: true, + isFromOnline: existing?.isFromOnline ?? false, + ); + } + + final repeaters = merged.values.toList() + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return repeaters; + } + + Future _refreshMyLocation() async { + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 0, + ), + ); + if (!mounted) return; + setState(() { + _myLocation = LatLng(position.latitude, position.longitude); + }); + } catch (_) { + // Best effort only. + } + } + + void _fitCameraIfNeeded(List<_MapRepeater> repeaters) { + if (_didFitCamera || repeaters.isEmpty || !mounted) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _didFitCamera || repeaters.isEmpty) { + return; + } + + if (repeaters.length == 1) { + final repeater = repeaters.first; + _mapController.move(LatLng(repeater.latitude, repeater.longitude), 13); + _currentZoom = 13; + } else { + final bounds = flutter_map.LatLngBounds.fromPoints( + repeaters + .map((node) => LatLng(node.latitude, node.longitude)) + .toList(), + ); + _mapController.fitCamera( + flutter_map.CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(48), + ), + ); + } + + _didFitCamera = true; + }); + } + + void _showRepeaterDetails(_MapRepeater repeater) { + final isAdding = _addingRepeaters.contains(repeater.publicKey); + final canAdd = !repeater.isFromContacts; + final isConnected = context + .read() + .deviceInfo + .isConnected; + + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + repeater.name, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + ), + SizedBox(height: 12), + _InfoLine( + label: AppLocalizations.of(context)!.publicKeyLabel, + value: repeater.publicKey, + monospace: true, + ), + _InfoLine( + label: AppLocalizations.of(context)!.coordinates, + value: + '${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}', + ), + _InfoLine(label: AppLocalizations.of(context)!.source, value: repeater.sourceLabel), + const SizedBox(height: 12), + if (canAdd) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: !isConnected || isAdding + ? null + : () async { + Navigator.of(context).pop(); + await _addRepeaterToContacts(repeater); + }, + icon: isAdding + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.person_add_alt_1), + label: Text( + !isConnected + ? 'Connect device to add' + : 'Add to contacts', + ), + ), + ) + else + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: null, + icon: Icon(Icons.check_circle), + label: Text(AppLocalizations.of(context)!.alreadyInContacts), + ), + ), + ], + ), + ), + ), + ); + } + + Future _addRepeaterToContacts(_MapRepeater repeater) async { + if (_addingRepeaters.contains(repeater.publicKey)) { + return; + } + + final connectionProvider = context.read(); + if (!connectionProvider.deviceInfo.isConnected) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.connectToADeviceBeforeAddingContacts), + ), + ); + return; + } + + setState(() { + _addingRepeaters.add(repeater.publicKey); + }); + + try { + await connectionProvider.getContact(_hexToBytes(repeater.publicKey)); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.addedToContacts(repeater.name))), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.failedToAddName(repeater.name, error.toString()))), + ); + } finally { + if (mounted) { + setState(() { + _addingRepeaters.remove(repeater.publicKey); + }); + } + } + } + + Uint8List _hexToBytes(String hex) { + final normalized = hex.replaceAll(':', '').trim().toLowerCase(); + return Uint8List.fromList( + List.generate( + normalized.length ~/ 2, + (index) => int.parse( + normalized.substring(index * 2, index * 2 + 2), + radix: 16, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final contactsProvider = context.watch(); + final repeaters = _mergeRepeaters( + onlineNodes: _onlineRepeaters, + contactRepeaters: contactsProvider.repeaters, + ); + _fitCameraIfNeeded(repeaters); + final clusters = _buildClusters(repeaters); + final markers = clusters.map((cluster) { + final markerColor = switch (cluster.status) { + _ClusterStatus.contactsOnly => const Color(0xFF7C3AED), + _ClusterStatus.onlineOnly => const Color(0xFFE8681D), + _ClusterStatus.both => const Color(0xFF1B8F4F), + _ClusterStatus.mixed => const Color(0xFF2563EB), + }; + + if (cluster.members.length == 1) { + final repeater = cluster.members.first; + return flutter_map.Marker( + point: cluster.center, + width: 80, + height: 100, + child: GestureDetector( + onTap: () => _showRepeaterDetails(repeater), + child: _RepeaterMarker(color: markerColor, label: repeater.name), + ), + ); + } + + return flutter_map.Marker( + point: cluster.center, + width: 48, + height: 48, + child: GestureDetector( + onTap: () => _zoomToCluster(cluster), + child: _ClusterMarker( + color: markerColor, + count: cluster.members.length, + ), + ), + ); + }).toList(); + final allMarkers = [ + ...markers, + if (_myLocation != null) + flutter_map.Marker( + point: _myLocation!, + width: 24, + height: 24, + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF2563EB), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + ), + ), + ]; + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.repeatersMap), + actions: [ + IconButton( + onPressed: _isLoading + ? null + : () => _loadRepeaters(forceRefresh: true), + icon: const Icon(Icons.refresh), + tooltip: AppLocalizations.of(context)!.refresh, + ), + ], + ), + body: Stack( + children: [ + flutter_map.FlutterMap( + mapController: _mapController, + options: flutter_map.MapOptions( + initialCenter: _fallbackCenter, + initialZoom: _fallbackZoom, + onPositionChanged: (camera, hasGesture) { + final nextZoom = camera.zoom; + if (nextZoom != _currentZoom && mounted) { + setState(() { + _currentZoom = nextZoom; + }); + } + }, + ), + children: [ + flutter_map.TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'app.meshcore.sar', + ), + flutter_map.MarkerLayer(markers: allMarkers), + ], + ), + if (_error != null) + Positioned( + top: 16, + left: 16, + right: 16, + child: Material( + elevation: 2, + borderRadius: BorderRadius.circular(14), + color: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text(_error!), + ), + ), + ), + if (_isLoading) const Center(child: CircularProgressIndicator()), + if (!_isLoading && repeaters.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + _error ?? 'No repeaters available.', + textAlign: TextAlign.center, + ), + ), + ), + if (repeaters.isNotEmpty) + Positioned( + left: 16, + top: _error == null ? 16 : 72, + child: Material( + elevation: 2, + borderRadius: BorderRadius.circular(14), + color: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _LegendRow( + color: const Color(0xFF7C3AED), + label: AppLocalizations.of(context)!.fromContacts, + ), + const SizedBox(height: 8), + _LegendRow( + color: const Color(0xFFE8681D), + label: AppLocalizations.of(context)!.onlineOnly, + ), + const SizedBox(height: 8), + _LegendRow(color: const Color(0xFF1B8F4F), label: AppLocalizations.of(context)!.inBoth), + ], + ), + ), + ), + ), + if (repeaters.isNotEmpty) + Positioned( + left: 16, + right: 16, + bottom: 16, + child: Material( + elevation: 3, + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text( + '${repeaters.length} repeaters', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: _jumpToMyLocation, + tooltip: AppLocalizations.of(context)!.myLocation, + child: const Icon(Icons.my_location), + ), + ); + } + + Future _jumpToMyLocation() async { + await _refreshMyLocation(); + final myLocation = _myLocation; + if (!mounted || myLocation == null) { + return; + } + + _mapController.move(myLocation, _myLocationZoom); + setState(() { + _currentZoom = _myLocationZoom; + }); + } + + List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) { + if (repeaters.isEmpty) { + return const []; + } + + if (_currentZoom >= 11.5) { + return repeaters + .map( + (repeater) => _RepeaterCluster( + members: [repeater], + center: LatLng(repeater.latitude, repeater.longitude), + status: repeater.clusterStatus, + ), + ) + .toList(); + } + + final cellSize = _gridSizeForZoom(_currentZoom); + final buckets = >{}; + + for (final repeater in repeaters) { + final latBucket = (repeater.latitude / cellSize).floor(); + final lonBucket = (repeater.longitude / cellSize).floor(); + final key = '$latBucket:$lonBucket'; + buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater); + } + + return buckets.values.map((members) { + final centerLat = + members.map((node) => node.latitude).reduce((a, b) => a + b) / + members.length; + final centerLon = + members.map((node) => node.longitude).reduce((a, b) => a + b) / + members.length; + final statuses = members.map((node) => node.clusterStatus).toSet(); + return _RepeaterCluster( + members: members, + center: LatLng(centerLat, centerLon), + status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed, + ); + }).toList(); + } + + double _gridSizeForZoom(double zoom) { + if (zoom < 5) return 3.0; + if (zoom < 7) return 1.5; + if (zoom < 9) return 0.7; + if (zoom < 10.5) return 0.28; + return 0.12; + } + + void _zoomToCluster(_RepeaterCluster cluster) { + if (cluster.members.isEmpty) { + return; + } + + final bounds = flutter_map.LatLngBounds.fromPoints( + cluster.members + .map((node) => LatLng(node.latitude, node.longitude)) + .toList(), + ); + _mapController.fitCamera( + flutter_map.CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(64), + maxZoom: (_currentZoom + 2).clamp(8, 14), + ), + ); + } +} + +class _InfoLine extends StatelessWidget { + final String label; + final String value; + final bool monospace; + + const _InfoLine({ + required this.label, + required this.value, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 92, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + Expanded( + child: Text( + value, + style: monospace + ? const TextStyle(fontFamily: 'monospace') + : null, + ), + ), + ], + ), + ); + } +} + +class _LegendRow extends StatelessWidget { + final Color color; + final String label; + + const _LegendRow({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text(label), + ], + ); + } +} + +class _RepeaterMarker extends StatelessWidget { + final Color color; + final String label; + + const _RepeaterMarker({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: const Icon(Icons.router, color: Colors.white, size: 18), + ), + const SizedBox(height: 2), + Container( + constraints: const BoxConstraints(maxWidth: 80), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], + ); + } +} + +class _ClusterMarker extends StatelessWidget { + final Color color; + final int count; + + const _ClusterMarker({required this.color, required this.count}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.22), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + alignment: Alignment.center, + child: Text( + '$count', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 14, + ), + ), + ); + } +} + +enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed } + +class _RepeaterCluster { + final List<_MapRepeater> members; + final LatLng center; + final _ClusterStatus status; + + const _RepeaterCluster({ + required this.members, + required this.center, + required this.status, + }); +} + +class _MapRepeater { + final String publicKey; + final String name; + final double latitude; + final double longitude; + final bool isFromContacts; + final bool isFromOnline; + + const _MapRepeater({ + required this.publicKey, + required this.name, + required this.latitude, + required this.longitude, + required this.isFromContacts, + required this.isFromOnline, + }); + + String get sourceLabel { + if (isFromContacts && isFromOnline) return 'Contacts and online'; + if (isFromContacts) return 'Contacts'; + return 'Online'; + } + + _ClusterStatus get clusterStatus { + if (isFromContacts && isFromOnline) return _ClusterStatus.both; + if (isFromContacts) return _ClusterStatus.contactsOnly; + return _ClusterStatus.onlineOnly; + } +} diff --git a/lib/screens/sar_template_management_screen.dart b/lib/screens/sar_template_management_screen.dart new file mode 100644 index 0000000..903f4c9 --- /dev/null +++ b/lib/screens/sar_template_management_screen.dart @@ -0,0 +1,456 @@ +import 'package:flutter/material.dart'; +import '../models/sar_template.dart'; +import '../services/sar_template_service.dart'; +import '../widgets/sar/sar_template_edit_dialog.dart'; +import '../l10n/app_localizations.dart'; + +/// Screen for managing SAR templates +class SarTemplateManagementScreen extends StatefulWidget { + const SarTemplateManagementScreen({super.key}); + + @override + State createState() => _SarTemplateManagementScreenState(); +} + +class _SarTemplateManagementScreenState extends State { + final SarTemplateService _templateService = SarTemplateService(); + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _initializeService(); + } + + Future _initializeService() async { + if (!_templateService.isInitialized) { + if (!mounted) return; + setState(() => _isLoading = true); + await _templateService.initialize(); + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _addTemplate() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => SarTemplateEditDialog( + onSave: (template) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context)!; + await _templateService.addTemplate(template); + if (mounted) { + messenger.showSnackBar( + SnackBar( + content: Text(l10n.templateAdded), + backgroundColor: Colors.green, + ), + ); + } + }, + ), + ); + } + + Future _editTemplate(SarTemplate template) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => SarTemplateEditDialog( + template: template, + onSave: (updatedTemplate) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context)!; + await _templateService.updateTemplate(template.id, updatedTemplate); + if (mounted) { + messenger.showSnackBar( + SnackBar( + content: Text(l10n.templateUpdated), + backgroundColor: Colors.green, + ), + ); + } + }, + ), + ); + } + + Future _deleteTemplate(SarTemplate template) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.deleteTemplate), + content: Text( + AppLocalizations.of(context)!.deleteTemplateConfirmation(template.getLocalizedName(context)), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.delete), + ), + ], + ), + ); + + if (confirmed == true) { + await _templateService.deleteTemplate(template.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.templateDeleted), + backgroundColor: Colors.orange, + ), + ); + } + } + } + + Future _importFromClipboard() async { + if (!mounted) return; + setState(() => _isLoading = true); + + try { + final importedCount = await _templateService.importFromClipboard(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.templatesImported(importedCount)), + backgroundColor: importedCount > 0 ? Colors.green : Colors.orange, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.importFailed(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _exportToClipboard() async { + try { + await _templateService.exportToClipboard(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.templatesExported(_templateService.templateCount), + ), + backgroundColor: Colors.green, + action: SnackBarAction( + label: AppLocalizations.of(context)!.ok, + textColor: Colors.white, + onPressed: () {}, + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.exportFailed(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _resetToDefaults() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.resetToDefaults), + content: Text(AppLocalizations.of(context)!.resetToDefaultsConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.reset), + ), + ], + ), + ); + + if (confirmed == true) { + await _templateService.resetToDefaults(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.resetComplete), + backgroundColor: Colors.green, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.sarTemplates), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: 'More options', + onSelected: (value) { + switch (value) { + case 'import': + _importFromClipboard(); + break; + case 'export': + _exportToClipboard(); + break; + case 'reset': + _resetToDefaults(); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'import', + child: ListTile( + leading: Icon(Icons.download), + title: Text(l10n.importFromClipboard), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem( + value: 'export', + child: ListTile( + leading: Icon(Icons.upload), + title: Text(l10n.exportToClipboard), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'reset', + child: ListTile( + leading: Icon(Icons.restart_alt), + title: Text(l10n.resetToDefaults), + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListenableBuilder( + listenable: _templateService, + builder: (context, child) { + final templates = _templateService.templates; + + if (templates.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.location_searching, + size: 64, + color: colorScheme.onSurface.withValues(alpha: 0.3), + ), + SizedBox(height: 16), + Text( + l10n.noTemplates, + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + SizedBox(height: 8), + Text( + l10n.tapAddToCreate, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: templates.length, + itemBuilder: (context, index) { + final template = templates[index]; + return _TemplateListItem( + template: template, + onTap: () => _editTemplate(template), + onDelete: () => _deleteTemplate(template), + ); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _addTemplate, + icon: Icon(Icons.add), + label: Text(l10n.addTemplate), + ), + ); + } +} + +/// Template list item widget +class _TemplateListItem extends StatelessWidget { + final SarTemplate template; + final VoidCallback onTap; + final VoidCallback onDelete; + + const _TemplateListItem({ + required this.template, + required this.onTap, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Dismissible( + key: Key(template.id), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + confirmDismiss: (direction) async { + // Show confirmation dialog + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.deleteTemplate), + content: Text( + AppLocalizations.of(context)!.deleteTemplateConfirmation(template.getLocalizedName(context)), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.delete), + ), + ], + ), + ); + }, + onDismissed: (direction) => onDelete(), + child: ListTile( + onTap: onTap, + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: template.color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: template.color.withValues(alpha: 0.3), + blurRadius: 4, + spreadRadius: 1, + ), + ], + ), + child: Center( + child: Text( + template.emoji, + style: const TextStyle(fontSize: 24), + ), + ), + ), + title: Row( + children: [ + Text( + template.getLocalizedName(context), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (template.isDefault) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.blue.withValues(alpha: 0.5), + ), + ), + child: Text( + 'DEFAULT', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.blue.shade700, + ), + ), + ), + ], + ], + ), + subtitle: template.description.isNotEmpty + ? Text( + template.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ) + : Text( + template.toSarMessage(), + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + color: Colors.red, + onPressed: onDelete, + ), + ), + ); + } +} diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart new file mode 100644 index 0000000..16eeb23 --- /dev/null +++ b/lib/screens/sensors_tab.dart @@ -0,0 +1,1242 @@ +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'; + +class SensorsTab extends StatefulWidget { + final bool isActive; + + const SensorsTab({super.key, this.isActive = true}); + + @override + State createState() => _SensorsTabState(); +} + +class _SensorsTabState extends State { + final Map _lastCenteredTelemetryAtBySensor = + {}; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + Future _showAddSensorSheet(BuildContext context) async { + final sensorsProvider = context.read(); + final contactsProvider = context.read(); + final candidates = sensorsProvider.availableCandidates( + contactsProvider, + connectionProvider: context.read(), + ); + await showModalBottomSheet( + context: context, + showDragHandle: true, + 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))), + ); + }, + ), + ); + } + + Future _showMetricSelector( + BuildContext context, + String publicKeyHex, + Contact? contact, + ) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (pageContext) => SensorCustomizeView( + publicKeyHex: publicKeyHex, + initialContact: contact, + onRenameMetric: + ({ + required BuildContext context, + required String publicKeyHex, + required SensorMetricOption option, + required SensorsProvider sensorsProvider, + }) { + return _showMetricRenameDialog( + context, + publicKeyHex: publicKeyHex, + option: option, + sensorsProvider: sensorsProvider, + ); + }, + ), + ), + ); + } + + Future _showMetricRenameDialog( + BuildContext context, { + required String publicKeyHex, + required SensorMetricOption option, + required SensorsProvider sensorsProvider, + }) async { + final controller = TextEditingController( + text: + sensorsProvider.labelOverrideFor(publicKeyHex, option.key) ?? + option.defaultLabel, + ); + final didSave = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(context)!.renameValue), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Set a custom label for ${option.label}.', + style: Theme.of(dialogContext).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: 'Label', + hintText: option.defaultLabel, + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => Navigator.of(dialogContext).pop(true), + ), + ], + ), + actions: [ + if (sensorsProvider.labelOverrideFor(publicKeyHex, option.key) != + null) + TextButton(onPressed: controller.clear, child: Text(AppLocalizations.of(dialogContext)!.reset)), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(AppLocalizations.of(dialogContext)!.save), + ), + ], + ), + ); + if (didSave != true) { + controller.dispose(); + return; + } + + final nextLabel = controller.text.trim(); + await sensorsProvider.setMetricLabel( + publicKeyHex, + option.key, + nextLabel == option.defaultLabel ? null : nextLabel, + ); + controller.dispose(); + } + + Future _refreshAll(BuildContext context) async { + await context.read().refreshAll( + contactsProvider: context.read(), + connectionProvider: context.read(), + ); + } + + void _maybeCenterMapOnTelemetryUpdate( + Iterable 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().navigateToLocation( + location: location, + zoom: 15.0, + ); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddSensorSheet(context), + child: const Icon(Icons.add), + ), + body: Consumer3( + builder: + ( + context, + sensorsProvider, + contactsProvider, + connectionProvider, + child, + ) { + final watchedKeys = sensorsProvider.watchedSensorKeys; + final hasPersistedSensors = watchedKeys.isNotEmpty; + final selfDisplayKey = sensorsProvider.displaySelfKey( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + final displayKeys = [ + ...?selfDisplayKey == null ? null : [selfDisplayKey], + ...watchedKeys, + ]; + _maybeCenterMapOnTelemetryUpdate( + displayKeys, + sensorsProvider: sensorsProvider, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + final hasAnyCards = + selfDisplayKey != null || watchedKeys.isNotEmpty; + + 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('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, + availableFieldKeys, + ), + labelOverrides: sensorsProvider.labelOverridesFor(key), + fieldSpans: { + for (final field in visibleFields) + field: sensorsProvider.fieldSpanFor(key, field), + }, + 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: connectionProvider, + ), + onPing: contact == null + ? null + : () async { + await showModalBottomSheet( + 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 AddSensorSheet extends StatefulWidget { + final List candidates; + final Future Function(Contact contact) onSelect; + final VoidCallback onClose; + + const AddSensorSheet({ + super.key, + required this.candidates, + required this.onSelect, + required this.onClose, + }); + + @override + State createState() => _AddSensorSheetState(); +} + +class _AddSensorSheetState extends State { + 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 Function({ + required BuildContext context, + required String publicKeyHex, + required SensorMetricOption option, + required SensorsProvider sensorsProvider, + }) + onRenameMetric; + + const SensorCustomizeView({ + super.key, + required this.publicKeyHex, + required this.initialContact, + required this.onRenameMetric, + this.showLivePreview = true, + }); + + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (context, sensorsProvider, contactsProvider, child) { + Contact? contact = initialContact; + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == publicKeyHex) { + contact = entry; + break; + } + } + + final options = sensorMetricOptionsFor( + contact, + labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex), + ); + final visibleFields = sensorsProvider.effectiveVisibleFieldsFor( + publicKeyHex, + options.map((option) => option.key), + ); + final autoRefreshMinutes = sensorsProvider.autoRefreshMinutesFor( + publicKeyHex, + ); + final orderedFieldKeys = sensorsProvider.metricOrderFor( + publicKeyHex, + options.map((option) => option.key), + ); + final optionByKey = { + for (final option in options) option.key: option, + }; + final orderedOptions = orderedFieldKeys + .map((fieldKey) => optionByKey[fieldKey]) + .whereType() + .toList(growable: false); + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.customizeSensor(contact?.displayName ?? AppLocalizations.of(context)!.sensor)), + ), + body: ListView( + padding: EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + 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.', + ), + ), + _SensorCustomizeSectionCard( + title: AppLocalizations.of(context)!.refreshSchedule, + subtitle: + 'Choose how often this sensor should refresh while your device stays connected.', + child: SensorAutoRefreshOptions( + selectedMinutes: autoRefreshMinutes, + onSelected: (minutes) { + sensorsProvider.setAutoRefreshMinutes( + publicKeyHex, + minutes, + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Field layout', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + 'Use the same value-card previews shown on the dashboard to control visibility, labels, width, and order.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ...orderedOptions.asMap().entries.map((entry) { + final index = entry.key; + final option = entry.value; + final visible = visibleFields.contains(option.key); + final span = sensorsProvider.fieldSpanFor( + publicKeyHex, + option.key, + ); + return SensorMetricSelectorItem( + option: option, + visible: visible, + span: span, + canMoveUp: index > 0, + canMoveDown: index < orderedOptions.length - 1, + onToggle: (value) { + sensorsProvider.toggleMetric( + publicKeyHex, + option.key, + value, + ); + }, + onRename: () => onRenameMetric( + context: context, + publicKeyHex: publicKeyHex, + option: option, + sensorsProvider: sensorsProvider, + ), + onMoveUp: index > 0 + ? () => sensorsProvider.moveMetric( + publicKeyHex, + availableFieldKeys: orderedFieldKeys, + oldIndex: index, + newIndex: index - 1, + ) + : null, + onMoveDown: index < orderedOptions.length - 1 + ? () => sensorsProvider.moveMetric( + publicKeyHex, + availableFieldKeys: orderedFieldKeys, + oldIndex: index, + newIndex: index + 1, + ) + : null, + onSpanChanged: (selection) { + sensorsProvider.setFieldSpan( + publicKeyHex, + option.key, + selection, + ); + }, + ); + }), + ], + ), + ); + }, + ); + } +} + +class _SensorCustomizeSectionCard extends StatelessWidget { + final String title; + final String subtitle; + final Widget child; + + const _SensorCustomizeSectionCard({ + required this.title, + required this.subtitle, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surfaceContainerLow, + colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), + ], + ), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.045), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 6), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + child, + ], + ), + ), + ); + } +} + +class SensorAutoRefreshOptions extends StatelessWidget { + final int selectedMinutes; + final ValueChanged onSelected; + + const SensorAutoRefreshOptions({ + super.key, + required this.selectedMinutes, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: SensorsProvider.supportedAutoRefreshIntervals + .map( + (minutes) => ChoiceChip( + label: Text(_formatAutoRefreshIntervalLabel(minutes)), + selected: selectedMinutes == minutes, + onSelected: (_) => onSelected(minutes), + ), + ) + .toList(growable: false), + ); + } +} + +String _formatAutoRefreshIntervalLabel(int minutes) { + if (minutes <= 0) { + return 'Off'; + } + if (minutes >= 360 && minutes % 60 == 0) { + return '${minutes ~/ 60}h'; + } + return '${minutes}m'; +} + +class SensorMetricSelectorItem extends StatelessWidget { + final SensorMetricOption option; + final bool visible; + final int span; + final bool canMoveUp; + final bool canMoveDown; + final ValueChanged onToggle; + final VoidCallback onRename; + final VoidCallback? onMoveUp; + final VoidCallback? onMoveDown; + final ValueChanged onSpanChanged; + + const SensorMetricSelectorItem({ + super.key, + required this.option, + required this.visible, + required this.span, + required this.canMoveUp, + required this.canMoveDown, + required this.onToggle, + required this.onRename, + this.onMoveUp, + this.onMoveDown, + required this.onSpanChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final showChannelChip = option.channel != null && option.channel != 1; + final previewCardData = + option.previewCardData ?? + SensorMetricCardData( + fieldKey: option.key, + icon: Icons.sensors, + label: option.defaultLabel, + value: option.valuePreview ?? 'No telemetry yet', + accent: colorScheme.primary, + channel: option.channel, + ); + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surfaceContainerLow, + colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), + ], + ), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.045), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + option.defaultLabel, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + TextButton.icon( + onPressed: onRename, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + foregroundColor: colorScheme.primary, + textStyle: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + icon: Icon(Icons.edit_outlined, size: 18), + label: Text(AppLocalizations.of(context)!.rename), + ), + if (showChannelChip) + Container( + key: ValueKey( + 'sensor_selector_channel_${option.key}', + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: previewCardData.accent.withValues( + alpha: 0.10, + ), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Channel ${option.channel}', + style: theme.textTheme.labelMedium?.copyWith( + color: previewCardData.accent, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: visible + ? const Color(0xFF218B63).withValues(alpha: 0.12) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + visible ? 'Visible' : 'Hidden', + style: theme.textTheme.labelMedium?.copyWith( + color: visible + ? const Color(0xFF218B63) + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Opacity( + opacity: visible ? 1 : 0.55, + child: SensorMetricTile( + data: previewCardData, + width: double.infinity, + keyPrefix: 'sensor_selector_metric', + allowMapPreview: false, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + 'Show on sensor card', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Switch(value: visible, onChanged: onToggle), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Text( + 'Card width', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + SegmentedButton( + segments: const [ + ButtonSegment(value: 1, label: Text('1x')), + ButtonSegment(value: 2, label: Text('2x')), + ], + selected: {span}, + onSelectionChanged: (selection) { + onSpanChanged(selection.first); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + 'Order', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + IconButton( + tooltip: AppLocalizations.of(context)!.moveUp, + onPressed: canMoveUp ? onMoveUp : null, + icon: const Icon(Icons.arrow_upward), + ), + IconButton( + tooltip: AppLocalizations.of(context)!.moveDown, + onPressed: canMoveDown ? onMoveDown : null, + icon: const Icon(Icons.arrow_downward), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _SensorCandidatePreview extends StatelessWidget { + final Contact contact; + + const _SensorCandidatePreview({required this.contact}); + + @override + Widget build(BuildContext context) { + final telemetry = contact.telemetry; + final previewLines = [ + '${contact.type.displayName} • ${contact.publicKeyShort}', + if (telemetry?.batteryPercentage != null) + 'Battery ${telemetry!.batteryPercentage!.toStringAsFixed(0)}% • ' + 'Temp ${telemetry.temperature?.toStringAsFixed(1) ?? '--'}°C', + if (telemetry?.gpsLocation != null) + 'GPS ${telemetry!.gpsLocation!.latitude.toStringAsFixed(3)}, ' + '${telemetry.gpsLocation!.longitude.toStringAsFixed(3)}', + ]; + + if (previewLines.length == 1) { + previewLines.add('No telemetry preview available yet'); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: previewLines + .take(3) + .map( + (line) => Text(line, maxLines: 1, overflow: TextOverflow.ellipsis), + ) + .toList(), + ); + } +} + +class _EmptySensorsState extends StatefulWidget { + const _EmptySensorsState(); + + @override + State<_EmptySensorsState> createState() => _EmptySensorsStateState(); +} + +class _EmptySensorsStateState extends State<_EmptySensorsState> { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Column( + children: [ + Container( + width: 84, + height: 84, + decoration: const BoxDecoration( + color: Color(0xFFDDEAF8), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.sensors_outlined, + size: 40, + color: Color(0xFF1E4F7A), + ), + ), + const SizedBox(height: 20), + Text( + 'No sensor nodes added', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 10), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text( + 'Use + to add discovered relays or nodes. Your device will appear here automatically when available.', + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } +} + +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 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), + ), + ), + ], + ), + ); + } +} + +IconData _typeIcon(Contact contact) { + if (contact.isSensor) { + return Icons.sensors; + } + if (contact.isRepeater) { + return Icons.router; + } + if (contact.isChat) { + return Icons.person; + } + return Icons.device_hub; +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..75f3144 --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,3051 @@ +import 'dart:typed_data'; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_avif/flutter_avif.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../providers/contacts_provider.dart'; +import '../providers/messages_provider.dart'; +import '../providers/app_provider.dart'; +import '../providers/connection_provider.dart'; +import '../providers/drawing_provider.dart'; +import '../providers/map_provider.dart'; +import '../models/contact.dart'; +import '../models/config_profile.dart'; +import '../services/location_tracking_service.dart'; +import '../services/locale_preferences.dart'; +import '../services/update_checker_service.dart'; +import '../services/voice_bitrate_preferences.dart'; +import '../services/image_preferences.dart'; +import '../services/route_hash_preferences.dart'; +import '../services/message_destination_preferences.dart'; +import '../services/image_codec_service.dart'; +import '../services/developer_mode_service.dart'; +import '../services/notification_service.dart'; +import '../services/profile_manager.dart'; +import '../services/profile_workspace_coordinator.dart'; +import '../services/profiles_feature_service.dart'; +import '../utils/sample_data_generator.dart'; +import '../utils/toast_logger.dart'; +import '../utils/image_message_parser.dart'; +import '../utils/voice_message_parser.dart'; +import '../theme/app_theme.dart'; +import '../l10n/app_localizations.dart'; +import '../widgets/update_dialog.dart'; +import '../widgets/settings/traffic_stats_reporting_section.dart'; +import 'sar_template_management_screen.dart'; +import 'profiles_screen.dart'; +import 'welcome_wizard_screen.dart'; + +class SettingsScreen extends StatefulWidget { + final Function(AppThemeMode) onThemeChanged; + final Function(Locale?) onLocaleChanged; + final AppThemeMode currentTheme; + final Locale? currentLocale; + + const SettingsScreen({ + super.key, + required this.onThemeChanged, + required this.onLocaleChanged, + required this.currentTheme, + required this.currentLocale, + }); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + static const String _publicChannelPublicKeyHex = + '0000000000000000000000000000000000000000000000000000000000000000'; + late AppThemeMode _selectedTheme; + late Locale? _selectedLocale; + PackageInfo? _packageInfo; + bool _isLoadingSampleData = false; + bool _showRxTxIndicators = true; + bool _isCheckingForUpdates = false; + int _voiceBitrate = VoiceBitratePreferences.defaultBitrate; + int _routeHashSize = RouteHashPreferences.defaultHashSize; + int _imageMaxSize = ImagePreferences.defaultMaxSize; + int _imageCompression = ImagePreferences.defaultQuality; + bool _imageGrayscale = ImagePreferences.defaultGrayscale; + bool _imageUltraMode = ImagePreferences.defaultUltraMode; + Uint8List? _previewSourceBytes; + String? _previewSourceName; + Uint8List? _previewCompressedBytes; + bool _isPreviewLoading = false; + bool _showCurrentImagePreview = true; + bool _fastLocationUpdatesEnabled = false; + double _fastLocationMovementThresholdMeters = 10.0; + int _fastLocationActiveCadenceSeconds = 10; + int? _fastLocationChannelIdx; + FastGpsRegionState? _fastGpsRegionState; + bool _rotateMapWithHeading = false; + bool _showMapDebugInfo = false; + bool _openMapInFullscreen = false; + bool _messageNotificationsEnabled = true; + bool _sarNotificationsEnabled = true; + bool _discoveryNotificationsEnabled = true; + bool _updateNotificationsEnabled = true; + bool _muteForegroundNotifications = true; + bool _isDeveloperModeEnabled = false; + bool _profilesEnabled = false; + bool _messageDestinationLockEnabled = false; + String _messageDestinationLockType = + MessageDestinationPreferences.destinationTypeChannel; + String? _messageDestinationLockPublicKey = _publicChannelPublicKeyHex; + int _versionTapCount = 0; + final ImagePicker _imagePicker = ImagePicker(); + final LocationTrackingService _locationService = LocationTrackingService(); + + @override + void initState() { + super.initState(); + _selectedTheme = widget.currentTheme; + _selectedLocale = widget.currentLocale; + _loadPackageInfo(); + _initializeLocationService(); + _loadRxTxPreference(); + _loadVoicePreferences(); + _loadRouteHashSizePreference(); + _loadImagePreferences(); + _loadFastLocationSettings(); + _loadDeveloperMode(); + _loadProfilesEnabled(); + _loadMapPreferences(); + _loadNotificationPreferences(); + _loadMessageDestinationLock(); + } + + @override + void dispose() { + // Clear location service callbacks to prevent memory leaks + _locationService.onError = null; + _locationService.onBroadcastSent = null; + _locationService.onTrackingStateChanged = null; + super.dispose(); + } + + Future _loadPackageInfo() async { + final info = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() { + _packageInfo = info; + }); + } + } + + Future _loadRxTxPreference() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + setState(() { + _showRxTxIndicators = + prefs.getBool( + ProfileStorageScope.scopedKey('show_rx_tx_indicators'), + ) ?? + true; + }); + } + } + + Future _loadDeveloperMode() async { + final isEnabled = await DeveloperModeService.isEnabled(); + if (!mounted) return; + setState(() { + _isDeveloperModeEnabled = isEnabled; + }); + } + + Future _loadProfilesEnabled() async { + final isEnabled = await ProfilesFeatureService.isEnabled(); + if (!mounted) return; + setState(() { + _profilesEnabled = isEnabled; + }); + } + + Future _loadNotificationPreferences() async { + final service = NotificationService(); + await service.initialize(); + if (!mounted) return; + setState(() { + _messageNotificationsEnabled = service.messageNotificationsEnabled; + _sarNotificationsEnabled = service.sarNotificationsEnabled; + _discoveryNotificationsEnabled = service.discoveryNotificationsEnabled; + _updateNotificationsEnabled = service.updateNotificationsEnabled; + _muteForegroundNotifications = service.muteForegroundNotifications; + }); + } + + Future _loadMessageDestinationLock() async { + final lockedDestination = + await MessageDestinationPreferences.getLockedDestination(); + if (!mounted) return; + + setState(() { + _messageDestinationLockEnabled = lockedDestination != null; + _messageDestinationLockType = lockedDestination?['publicKey'] == null + ? MessageDestinationPreferences.destinationTypeChannel + : lockedDestination?['type'] ?? + MessageDestinationPreferences.destinationTypeChannel; + _messageDestinationLockPublicKey = + lockedDestination?['publicKey'] ?? _publicChannelPublicKeyHex; + }); + } + + List _messageDestinationLockOptions( + ContactsProvider contactsProvider, + ) { + final channels = List.from(contactsProvider.channels) + ..sort((a, b) { + if (a.isPublicChannel != b.isPublicChannel) { + return a.isPublicChannel ? -1 : 1; + } + return a.displayName.toLowerCase().compareTo( + b.displayName.toLowerCase(), + ); + }); + final rooms = List.from(contactsProvider.rooms) + ..sort( + (a, b) => + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), + ); + + return [...channels, ...rooms]; + } + + String _messageDestinationLockLabel(BuildContext context, Contact contact) { + final name = contact.isChannel + ? contact.getLocalizedDisplayName(context) + : contact.displayName; + return contact.isRoom ? 'Room: $name' : 'Channel: $name'; + } + + String _messageDestinationLockTypeForContact(Contact contact) { + return contact.isRoom + ? MessageDestinationPreferences.destinationTypeRoom + : MessageDestinationPreferences.destinationTypeChannel; + } + + String? _selectedMessageDestinationLockValue(List destinations) { + final currentValue = _messageDestinationLockPublicKey; + if (currentValue != null && + destinations.any((contact) => contact.publicKeyHex == currentValue)) { + return currentValue; + } + + return destinations.isEmpty ? null : destinations.first.publicKeyHex; + } + + Future _setMessageDestinationLock({ + required bool enabled, + String? type, + String? recipientPublicKey, + }) async { + final nextType = type ?? _messageDestinationLockType; + final nextRecipientPublicKey = + recipientPublicKey ?? + _messageDestinationLockPublicKey ?? + _publicChannelPublicKeyHex; + + await MessageDestinationPreferences.setLockedDestination( + enabled: enabled, + type: nextType, + recipientPublicKey: enabled ? nextRecipientPublicKey : null, + ); + + if (!mounted) return; + + // Simple mode requires a locked destination; disabling the lock exits it. + final appProvider = context.read(); + if (!enabled && appProvider.isSimpleMode) { + await appProvider.toggleSimpleMode(false); + } + + if (!mounted) return; + + setState(() { + _messageDestinationLockEnabled = enabled; + _messageDestinationLockType = nextType; + _messageDestinationLockPublicKey = nextRecipientPublicKey; + }); + } + + Future _handleVersionTap() async { + if (_isDeveloperModeEnabled) { + await DeveloperModeService.setEnabled(false); + if (!mounted) return; + setState(() { + _isDeveloperModeEnabled = false; + _versionTapCount = 0; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.developerModeDisabled), + ), + ); + return; + } + + final nextTapCount = _versionTapCount + 1; + if (nextTapCount >= 3) { + await DeveloperModeService.setEnabled(true); + if (!mounted) return; + setState(() { + _isDeveloperModeEnabled = true; + _versionTapCount = 0; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.developerModeEnabled), + ), + ); + return; + } + + if (!mounted) return; + setState(() { + _versionTapCount = nextTapCount; + }); + } + + Future _saveRxTxPreference(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + ProfileStorageScope.scopedKey('show_rx_tx_indicators'), + value, + ); + } + + Future _loadMapPreferences() async { + final prefs = await SharedPreferences.getInstance(); + if (!mounted) return; + setState(() { + _rotateMapWithHeading = + prefs.getBool( + ProfileStorageScope.scopedKey('map_rotate_with_heading'), + ) ?? + false; + _showMapDebugInfo = + prefs.getBool(ProfileStorageScope.scopedKey('map_show_debug_info')) ?? + false; + _openMapInFullscreen = + prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ?? + false; + }); + } + + Future _saveMapPreference(String key, bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(ProfileStorageScope.scopedKey(key), value); + } + + Future _loadVoicePreferences() async { + final value = await VoiceBitratePreferences.getBitrate(); + if (!mounted) return; + setState(() { + _voiceBitrate = value; + }); + } + + Future _saveVoiceBitratePreference(int value) async { + await VoiceBitratePreferences.setBitrate(value); + if (!mounted) return; + setState(() { + _voiceBitrate = value; + }); + } + + String _voiceBitrateSubtitle(int bitrate) { + return '$bitrate bps'; + } + + Future _loadRouteHashSizePreference() async { + final value = await RouteHashPreferences.getHashSize(); + if (!mounted) return; + setState(() { + _routeHashSize = value; + }); + } + + Future _saveRouteHashSizePreference(int value) async { + await RouteHashPreferences.setHashSize(value); + if (!mounted) return; + setState(() { + _routeHashSize = value; + }); + } + + Future _loadImagePreferences() async { + final size = await ImagePreferences.getMaxSize(); + final compression = await ImagePreferences.getCompression(); + final grayscale = await ImagePreferences.getGrayscale(); + final ultraMode = await ImagePreferences.getUltraMode(); + if (!mounted) return; + setState(() { + _imageMaxSize = ImagePreferences.effectiveMaxSize( + size, + ultraMode: ultraMode, + ); + _imageCompression = compression; + _imageGrayscale = grayscale; + _imageUltraMode = ultraMode; + }); + await _refreshImageModePreview(); + } + + Future _loadFastLocationSettings() async { + await _locationService.loadSettings(); + if (!mounted) return; + setState(() { + _fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled; + _fastLocationMovementThresholdMeters = + _locationService.fastLocationMovementThresholdMeters; + _fastLocationActiveCadenceSeconds = + _locationService.fastLocationActiveCadenceSeconds; + _fastLocationChannelIdx = _locationService.fastLocationChannelIdx; + }); + await _loadFastGpsRegionState(); + } + + Future _loadFastGpsRegionState() async { + final appProvider = context.read(); + FastGpsRegionState? regionState; + try { + regionState = await appProvider.getFastGpsRegionState(); + } catch (_) { + regionState = null; + } + if (!mounted) return; + setState(() => _fastGpsRegionState = regionState); + } + + Future _editFastGpsRegion() async { + final regionState = _fastGpsRegionState; + if (regionState == null) return; + + final selected = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < regionState.labels.length; i++) + ListTile( + leading: Icon(i == 0 ? Icons.public : Icons.travel_explore), + title: Text(regionState.labels[i]), + trailing: regionState.selectedIndex == i + ? const Icon(Icons.check) + : null, + onTap: () => Navigator.pop(sheetContext, i), + ), + ], + ), + ), + ); + + if (selected == null || selected == regionState.selectedIndex) return; + try { + await context.read().setFastGpsRegion(selected); + await _loadFastGpsRegionState(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to set region: $e'), + backgroundColor: Colors.orange, + ), + ); + } + } + + Future _setFastLocationUpdatesEnabled(bool enabled) async { + await _locationService.setFastLocationUpdatesEnabled(enabled); + if (!mounted) return; + context.read().notifyChannelLocationSharingChanged(); + setState(() { + _fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled; + }); + } + + Future _editFastLocationMovementThreshold() async { + final controller = TextEditingController( + text: _fastLocationMovementThresholdMeters.toStringAsFixed(0), + ); + final value = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.fastGpsMovementThreshold), + content: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Meters', + helperText: 'Valid range: 10 to 1000 meters', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () { + final parsed = double.tryParse(controller.text.trim()); + if (parsed == null) return; + Navigator.pop(context, parsed.clamp(10.0, 1000.0)); + }, + child: Text(AppLocalizations.of(context)!.save), + ), + ], + ), + ); + if (value == null) return; + await _locationService.updateFastLocationMovementThreshold(value); + if (!mounted) return; + setState(() { + _fastLocationMovementThresholdMeters = + _locationService.fastLocationMovementThresholdMeters; + }); + } + + Future _editFastLocationActiveCadence() async { + final controller = TextEditingController( + text: _fastLocationActiveCadenceSeconds.toString(), + ); + final value = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.fastGpsActiveuseInterval), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Seconds', + helperText: 'Fixed at 60 seconds', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () { + final parsed = int.tryParse(controller.text.trim()); + if (parsed == null) return; + Navigator.pop(context, parsed.clamp(60, 60)); + }, + child: Text(AppLocalizations.of(context)!.save), + ), + ], + ), + ); + if (value == null) return; + await _locationService.updateFastLocationActiveCadenceSeconds(value); + if (!mounted) return; + setState(() { + _fastLocationActiveCadenceSeconds = + _locationService.fastLocationActiveCadenceSeconds; + }); + } + + String _describeFastLocationChannel(List channels) { + final channelIdx = _fastLocationChannelIdx; + if (channelIdx == null) { + return 'Not set'; + } + + for (final channel in channels) { + final idx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + if (idx == channelIdx) { + return '${channel.getLocalizedDisplayName(context)} (slot $channelIdx)'; + } + } + + return 'Channel $channelIdx unavailable'; + } + + Future _sendTestFastLocationUpdate() async { + final appProvider = context.read(); + final sent = await appProvider.sendTestFastLocationUpdate(); + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + sent + ? 'Test fast GPS update sent.' + : 'Unable to send test fast GPS update.', + ), + backgroundColor: sent ? Colors.green : Colors.orange, + ), + ); + } + + Future _editFastLocationChannel() async { + final channels = + List.from(context.read().channels) + ..removeWhere((c) => c.isPublicChannel) + ..sort((a, b) { + final aIdx = a.publicKey.length > 1 ? a.publicKey[1] : 0; + final bIdx = b.publicKey.length > 1 ? b.publicKey[1] : 0; + return aIdx.compareTo(bIdx); + }); + + final selected = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.block), + title: Text( + AppLocalizations.of(context)!.disableFastGpsPublishing, + ), + trailing: _fastLocationChannelIdx == null + ? const Icon(Icons.check) + : null, + onTap: () => Navigator.pop(sheetContext, -1), + ), + for (final channel in channels) + ListTile( + leading: const Icon(Icons.tag), + title: Text(channel.getLocalizedDisplayName(sheetContext)), + subtitle: Text( + 'Channel ${channel.publicKey.length > 1 ? channel.publicKey[1] : 0}', + ), + trailing: + _fastLocationChannelIdx == + (channel.publicKey.length > 1 + ? channel.publicKey[1] + : 0) + ? const Icon(Icons.check) + : null, + onTap: () => Navigator.pop( + sheetContext, + channel.publicKey.length > 1 ? channel.publicKey[1] : 0, + ), + ), + ], + ), + ), + ); + + if (selected == null) return; + await _locationService.updateFastLocationChannelIdx( + selected < 0 ? null : selected, + ); + if (!mounted) return; + context.read().notifyChannelLocationSharingChanged(); + setState(() { + _fastLocationChannelIdx = _locationService.fastLocationChannelIdx; + }); + } + + Future _saveImageMaxSize(int size) async { + await ImagePreferences.setMaxSize(size); + if (!mounted) return; + setState(() => _imageMaxSize = size); + await _refreshImageModePreview(); + } + + Future _saveImageCompression(int compression) async { + await ImagePreferences.setCompression(compression); + if (!mounted) return; + setState(() => _imageCompression = compression); + await _refreshImageModePreview(); + } + + Future _refreshImageModePreview() async { + final sourceBytes = _previewSourceBytes; + if (sourceBytes == null) { + if (!mounted) return; + setState(() => _previewCompressedBytes = null); + return; + } + + setState(() => _isPreviewLoading = true); + try { + final result = await ImageCodecService.compress( + sourceBytes, + maxDimension: ImagePreferences.effectiveMaxSize( + _imageMaxSize, + ultraMode: _imageUltraMode, + ), + compression: _imageCompression, + grayscale: _imageGrayscale, + ultraMode: _imageUltraMode, + ); + if (!mounted) return; + setState(() { + _previewCompressedBytes = result?.bytes; + _isPreviewLoading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _previewCompressedBytes = null; + _isPreviewLoading = false; + }); + } + } + + Future _selectPreviewImageFromGallery() async { + try { + final picked = await _imagePicker.pickImage(source: ImageSource.gallery); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + if (!mounted) return; + setState(() { + _previewSourceBytes = bytes; + _previewSourceName = picked.name; + }); + await _refreshImageModePreview(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.failedToLoadPreviewImage(e.toString()), + ), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _initializeLocationService() async { + // Initialize location service with BLE service + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (mounted) { + final appProvider = context.read(); + await _locationService.initialize( + appProvider.connectionProvider.bleService, + ); + + // Set up callbacks for UI feedback + _locationService.onError = (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error), backgroundColor: Colors.orange), + ); + } + }; + + _locationService.onBroadcastSent = (position) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.locationBroadcast( + position.latitude.toStringAsFixed(5), + position.longitude.toStringAsFixed(5), + ), + ), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } + }; + + _locationService.onTrackingStateChanged = (isTracking) { + if (mounted) { + setState(() {}); + } + }; + + // Load settings and restore tracking state + final prefs = await SharedPreferences.getInstance(); + final wasTracking = + prefs.getBool( + ProfileStorageScope.scopedKey('background_tracking_enabled'), + ) ?? + false; + + if (wasTracking) { + await _startBackgroundTracking(); + } + + if (mounted) { + setState(() {}); + } + } + }); + } + + Future _saveThemePreference(AppThemeMode theme) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('theme_mode', theme.name); + } + + void _handleThemeChange(AppThemeMode? theme) { + if (theme != null) { + setState(() { + _selectedTheme = theme; + }); + _saveThemePreference(theme); + widget.onThemeChanged(theme); + } + } + + Future _saveLocalePreference(Locale? locale) async { + await LocalePreferences.setLocale(locale); + } + + /// Check for app updates and show notification or dialog + Future _checkForUpdates() async { + // Only on Android + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid, + ), + backgroundColor: Colors.orange, + ), + ); + } + return; + } + + setState(() { + _isCheckingForUpdates = true; + }); + + try { + debugPrint('[Settings] Checking for updates...'); + final updateInfo = await UpdateCheckerService().checkForUpdate(); + + if (!mounted) return; + + setState(() { + _isCheckingForUpdates = false; + }); + + if (!updateInfo.isAvailable) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.youAreRunningTheLatestVersion, + ), + backgroundColor: Colors.green, + ), + ); + return; + } + + if (updateInfo.downloadUrl == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.updateAvailableButDownloadUrlNotFound, + ), + backgroundColor: Colors.orange, + ), + ); + return; + } + + // Show dialog with update details + UpdateDialog.show(context, updateInfo); + } catch (e) { + debugPrint('[Settings] Error checking for updates: $e'); + if (mounted) { + setState(() { + _isCheckingForUpdates = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.errorCheckingForUpdates(e.toString()), + ), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _handleLocaleChange(Locale? locale) { + setState(() { + _selectedLocale = locale; + }); + _saveLocalePreference(locale); + widget.onLocaleChanged(locale); + } + + Future _loadSampleData() async { + setState(() => _isLoadingSampleData = true); + + try { + // Get current location or use default + LatLng centerLocation; + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + timeLimit: Duration(seconds: 5), + ), + ); + centerLocation = LatLng(position.latitude, position.longitude); + } catch (e) { + // Default to Ljubljana, Slovenia if location unavailable + centerLocation = const LatLng(46.0569, 14.5058); + } + + if (!mounted) return; + + // Get localization + final l10n = AppLocalizations.of(context)!; + + // Generate sample data + final contacts = SampleDataGenerator.generateContacts( + centerLocation: centerLocation, + l10n: l10n, + teamMemberCount: 5, + channelCount: 2, + ); + + final sampleMessages = SampleDataGenerator.generateAllMessages( + centerLocation: centerLocation, + l10n: l10n, + foundPersonCount: 2, + fireCount: 1, + stagingCount: 1, + objectCount: 1, + generalChannelMessages: 8, + emergencyChannelMessages: 5, + ); + + // Add to providers + final contactsProvider = Provider.of( + context, + listen: false, + ); + final messagesProvider = Provider.of( + context, + listen: false, + ); + + contactsProvider.addContacts(contacts); + for (final message in sampleMessages.messages) { + messagesProvider.addMessage( + message, + contactLocationSnapshot: sampleMessages.contactLocations[message.id], + ); + } + + if (!mounted) return; + + final teamCount = contacts.where((c) => c.isChat).length; + final channelCount = contacts.where((c) => c.isRoom).length; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.loadedSampleData( + teamCount, + channelCount, + sampleMessages.messages.where((m) => m.isSarMarker).length, + sampleMessages.messages.length, + ), + ), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.failedToLoadSampleData(e.toString()), + ), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() => _isLoadingSampleData = false); + } + } + } + + Future _handleLocationPermissionTap() async { + try { + final permission = await Geolocator.checkPermission(); + + if (permission == LocationPermission.deniedForever) { + // Show dialog to open app settings + if (!mounted) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.settings, size: 24), + SizedBox(width: 12), + Text(AppLocalizations.of(context)!.locationPermission), + ], + ), + content: Text( + AppLocalizations.of(context)!.locationPermissionDialogContent, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); + await Geolocator.openAppSettings(); + }, + child: Text(AppLocalizations.of(context)!.openSettings), + ), + ], + ), + ); + } else if (permission == LocationPermission.denied) { + // Request permission + final newPermission = await Geolocator.requestPermission(); + + if (!mounted) return; + + if (newPermission == LocationPermission.whileInUse || + newPermission == LocationPermission.always) { + // Permission granted + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.locationPermissionGranted, + ), + backgroundColor: Colors.green, + ), + ); + setState(() {}); // Refresh UI to show new status + } else { + // Permission denied + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.locationPermissionRequiredForGps, + ), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 4), + ), + ); + } + } else { + // Already granted - show info + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.locationPermissionAlreadyGranted, + ), + backgroundColor: Colors.blue, + ), + ); + } + } catch (e) { + debugPrint('Error handling location permission: $e'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.errorGeneric(e.toString()), + ), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _startBackgroundTracking() async { + final success = await _locationService.startTracking( + distanceThreshold: _locationService.gpsUpdateDistance, + ); + + if (!success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.failedToStartBackgroundTracking, + ), + duration: const Duration(seconds: 3), + ), + ); + } + + if (mounted) { + setState(() {}); + } + } + + Future _clearSampleData() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.clearAllDataConfirmTitle), + content: Text(AppLocalizations.of(context)!.clearAllDataConfirmMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.clear), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final contactsProvider = Provider.of( + context, + listen: false, + ); + final messagesProvider = Provider.of( + context, + listen: false, + ); + + contactsProvider.clearContacts(); + messagesProvider.clearAll(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.allDataCleared), + backgroundColor: Colors.orange, + ), + ); + } + + Future _clearMessages() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.clearMessages), + content: const Text( + 'This will permanently delete all stored messages. Are you sure?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.clear), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final messagesProvider = Provider.of( + context, + listen: false, + ); + messagesProvider.clearMessages(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.allMessagesCleared), + backgroundColor: Colors.orange, + ), + ); + } + + Future _showRouteHashSizeDialog() async { + final selected = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text(AppLocalizations.of(context)!.routePathByteSize), + children: [ + for (final value in RouteHashPreferences.supportedSizes) + SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(value), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('$value-byte (max ${64 ~/ value} hops)'), + Text( + 'Use ${value * 2} hex characters per hop', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + if (_routeHashSize == value) + Icon( + Icons.check, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ], + ), + ); + + if (selected == null) return; + await _saveRouteHashSizePreference(selected); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( + appBar: AppBar(title: Text(l10n.settings)), + body: ColoredBox( + color: colorScheme.surface, + child: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + // ── Appearance ── + _buildSection( + icon: Icons.palette_rounded, + title: AppLocalizations.of(context)!.appearance, + subtitle: AppLocalizations.of( + context, + )!.themeLanguageAndDisplayPreferences, + children: [ + ListTile( + dense: true, + leading: const Icon(Icons.palette, size: 20), + title: Text(l10n.theme), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppTheme.getThemeDisplayName(_selectedTheme), + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => _showThemeDialog(), + ), + ListTile( + dense: true, + leading: const Icon(Icons.language, size: 20), + title: Text(l10n.language), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocalePreferences.getDisplayName(_selectedLocale), + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => _showLanguageDialog(), + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.radar, size: 20), + title: Text(l10n.showRxTxIndicators), + value: _showRxTxIndicators, + onChanged: (value) async { + setState(() => _showRxTxIndicators = value); + await _saveRxTxPreference(value); + }, + ), + Consumer( + builder: (context, appProvider, child) => ListTile( + dense: true, + leading: const Icon(Icons.format_size, size: 20), + title: Text(l10n.messageFontSize), + trailing: SizedBox( + width: 140, + child: Slider( + value: appProvider.messageFontScale, + min: 0.85, + max: 1.4, + divisions: 11, + label: + '${(appProvider.messageFontScale * 100).round()}%', + onChanged: (value) { + appProvider.setMessageFontScale(value); + }, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // ── Notifications ── + _buildSection( + icon: Icons.notifications_outlined, + title: AppLocalizations.of(context)!.notifications, + subtitle: AppLocalizations.of( + context, + )!.controlWhichAlertsYouReceive, + children: [ + SwitchListTile( + dense: true, + secondary: const Icon(Icons.chat_bubble_outline, size: 20), + title: Text(l10n.messageNotifications), + value: _messageNotificationsEnabled, + onChanged: (value) async { + setState(() => _messageNotificationsEnabled = value); + await NotificationService().setMessageNotificationsEnabled( + value, + ); + }, + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.warning_amber_outlined, size: 20), + title: Text(l10n.sarAlerts), + value: _sarNotificationsEnabled, + onChanged: (value) async { + setState(() => _sarNotificationsEnabled = value); + await NotificationService().setSarNotificationsEnabled( + value, + ); + }, + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.contact_page_outlined, size: 20), + title: Text(l10n.discoveryNotifications), + value: _discoveryNotificationsEnabled, + onChanged: (value) async { + setState(() => _discoveryNotificationsEnabled = value); + await NotificationService() + .setDiscoveryNotificationsEnabled(value); + }, + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.system_update, size: 20), + title: Text(l10n.updateNotifications), + value: _updateNotificationsEnabled, + onChanged: (value) async { + setState(() => _updateNotificationsEnabled = value); + await NotificationService().setUpdateNotificationsEnabled( + value, + ); + }, + ), + SwitchListTile( + dense: true, + secondary: const Icon( + Icons.visibility_off_outlined, + size: 20, + ), + title: Text(l10n.muteWhileAppIsOpen), + subtitle: Text( + AppLocalizations.of( + context, + )!.suppressNotificationsWhileInForeground, + ), + value: _muteForegroundNotifications, + onChanged: (value) async { + setState(() => _muteForegroundNotifications = value); + await NotificationService().setMuteForegroundNotifications( + value, + ); + }, + ), + ], + ), + const SizedBox(height: 12), + + // ── Tabs & Navigation ── + Consumer( + builder: (context, appProvider, child) => _buildSection( + icon: Icons.dashboard_customize_rounded, + title: AppLocalizations.of(context)!.tabsAndNavigation, + subtitle: AppLocalizations.of( + context, + )!.chooseWhichTabsAndContactSectionsToShow, + children: [ + SwitchListTile( + dense: true, + secondary: const Icon( + Icons.center_focus_strong_outlined, + size: 20, + ), + title: Text(l10n.simpleMode), + subtitle: Text(l10n.simpleModeDescription), + value: appProvider.isSimpleMode, + onChanged: (value) async { + if (value && !_messageDestinationLockEnabled) { + ToastLogger.error( + context, + AppLocalizations.of( + context, + )!.simpleModeRequiresLockedDestination, + ); + return; + } + await appProvider.toggleSimpleMode(value); + }, + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.map_outlined, size: 20), + title: Text(l10n.disableMap), + value: !appProvider.isMapEnabled, + onChanged: appProvider.isSimpleMode + ? null + : (value) async { + await appProvider.toggleMapEnabled(!value); + }, + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.contacts_outlined, size: 20), + title: Text(l10n.disableContacts), + value: !appProvider.isContactsEnabled, + onChanged: appProvider.isSimpleMode + ? null + : (value) async { + await appProvider.toggleContactsEnabled(!value); + }, + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.sensors, size: 20), + title: Text(l10n.enableSensorsTab), + value: appProvider.isSensorsEnabled, + onChanged: appProvider.isSimpleMode + ? null + : (value) async { + await appProvider.toggleSensorsEnabled(value); + }, + ), + Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 4), + child: Text( + 'Contacts tab sections', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + _buildCompactToggleRow( + icon: Icons.star_outline_rounded, + label: l10n.favourites, + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.favourites, + ), + onChanged: appProvider.isSimpleMode + ? null + : (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.favourites, + v, + ), + ), + _buildCompactToggleRow( + icon: Icons.people_alt_outlined, + label: l10n.teamMembers, + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.teamMembers, + ), + onChanged: appProvider.isSimpleMode + ? null + : (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.teamMembers, + v, + ), + ), + _buildCompactToggleRow( + icon: Icons.router_outlined, + label: l10n.repeaters, + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.repeaters, + ), + onChanged: appProvider.isSimpleMode + ? null + : (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.repeaters, + v, + ), + ), + _buildCompactToggleRow( + icon: Icons.sensors_outlined, + label: l10n.sensors, + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.sensors, + ), + onChanged: appProvider.isSimpleMode + ? null + : (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.sensors, + v, + ), + ), + _buildCompactToggleRow( + icon: Icons.meeting_room_outlined, + label: l10n.rooms, + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.rooms, + ), + onChanged: appProvider.isSimpleMode + ? null + : (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.rooms, + v, + ), + ), + _buildCompactToggleRow( + icon: Icons.broadcast_on_personal_outlined, + label: l10n.channels, + value: appProvider.isContactsSectionEnabled( + ContactsTabSection.channels, + ), + onChanged: appProvider.isSimpleMode + ? null + : (v) => appProvider.setContactsSectionEnabled( + ContactsTabSection.channels, + v, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + + // ── Messaging ── + _buildSection( + icon: Icons.chat_rounded, + title: AppLocalizations.of(context)!.messaging, + subtitle: AppLocalizations.of( + context, + )!.routingRetriesAndDestinationLock, + children: [ + ListTile( + dense: true, + leading: const Icon(Icons.alt_route, size: 20), + title: Text(l10n.routePathByteSize), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'}', + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: _showRouteHashSizeDialog, + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.route, size: 20), + title: Text(l10n.nearestRepeaterFallback), + subtitle: Text( + AppLocalizations.of( + context, + )!.resendThroughNearestRepeaterOnFailure, + ), + value: appProvider.nearestRelayFallbackEnabled, + onChanged: (value) async { + await appProvider.toggleNearestRelayFallbackEnabled( + value, + ); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.cleaning_services, size: 20), + title: Text(l10n.clearPathOnMaxRetry), + subtitle: Text( + AppLocalizations.of( + context, + )!.clearRouteOnlyAfterAllRetriesFail, + ), + value: appProvider.clearPathOnMaxRetry, + onChanged: (value) async { + await appProvider.toggleClearPathOnMaxRetry(value); + }, + ), + ), + const Divider(height: 1), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.lock_outline, size: 20), + title: Text(AppLocalizations.of(context)!.lockDestination), + subtitle: const Text( + 'Fix Messages tab to one channel or room', + ), + value: _messageDestinationLockEnabled, + onChanged: (value) async { + final contactsProvider = context.read(); + final options = _messageDestinationLockOptions( + contactsProvider, + ); + final selectedPublicKey = + _selectedMessageDestinationLockValue(options) ?? + _publicChannelPublicKeyHex; + final selectedContact = options.where((contact) { + return contact.publicKeyHex == selectedPublicKey; + }).firstOrNull; + + await _setMessageDestinationLock( + enabled: value, + type: selectedContact == null + ? MessageDestinationPreferences.destinationTypeChannel + : _messageDestinationLockTypeForContact( + selectedContact, + ), + recipientPublicKey: selectedPublicKey, + ); + }, + ), + if (_messageDestinationLockEnabled) + Consumer( + builder: (context, contactsProvider, child) { + final options = _messageDestinationLockOptions( + contactsProvider, + ); + final selectedValue = + _selectedMessageDestinationLockValue(options); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: DropdownButtonFormField( + key: ValueKey(selectedValue), + initialValue: selectedValue, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Locked channel or room', + prefixIcon: Icon(Icons.forum_outlined), + border: OutlineInputBorder(), + isDense: true, + ), + items: [ + for (final contact in options) + DropdownMenuItem( + value: contact.publicKeyHex, + child: Text( + _messageDestinationLockLabel( + context, + contact, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: options.isNotEmpty + ? (value) async { + if (value == null) return; + final selectedContact = options.where(( + contact, + ) { + return contact.publicKeyHex == value; + }).firstOrNull; + if (selectedContact == null) return; + + await _setMessageDestinationLock( + enabled: true, + type: _messageDestinationLockTypeForContact( + selectedContact, + ), + recipientPublicKey: value, + ); + } + : null, + ), + ); + }, + ), + ], + ), + const SizedBox(height: 12), + + // ── Map ── + _buildSection( + icon: Icons.map_rounded, + title: AppLocalizations.of(context)!.map, + children: [ + SwitchListTile( + dense: true, + secondary: const Icon(Icons.explore, size: 20), + title: Text(l10n.rotateMapWithHeading), + value: _rotateMapWithHeading, + onChanged: (value) async { + setState(() => _rotateMapWithHeading = value); + await _saveMapPreference('map_rotate_with_heading', value); + }, + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.fullscreen, size: 20), + title: Text(l10n.openMapInFullscreen), + value: _openMapInFullscreen, + onChanged: (value) async { + setState(() => _openMapInFullscreen = value); + await _saveMapPreference('map_fullscreen', value); + }, + ), + Consumer( + builder: (context, drawingProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.fmd_good_outlined, size: 20), + title: Text(l10n.showSarMarkersLabel), + value: drawingProvider.showSarMarkers, + onChanged: (value) { + drawingProvider.toggleSarMarkers(); + }, + ), + ), + Consumer( + builder: (context, mapProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.router_outlined, size: 20), + title: Text(l10n.hideRepeatersOnMap), + value: mapProvider.hideRepeatersOnMap, + onChanged: (value) async { + await mapProvider.setHideRepeatersOnMap(value); + }, + ), + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.bug_report_outlined, size: 20), + title: Text(l10n.showMapDebugInfo), + value: _showMapDebugInfo, + onChanged: (value) async { + setState(() => _showMapDebugInfo = value); + await _saveMapPreference('map_show_debug_info', value); + }, + ), + ], + ), + const SizedBox(height: 12), + + // ── GPS & Location ── + _buildSection( + icon: Icons.gps_fixed, + title: AppLocalizations.of(context)!.gpsAndLocation, + subtitle: AppLocalizations.of( + context, + )!.fastUpdatesThresholdsAndPermissions, + children: [ + SwitchListTile( + dense: true, + secondary: const Icon(Icons.gps_fixed, size: 20), + title: Text(l10n.fastPrivateGpsUpdates), + subtitle: const Text( + 'Zero-hop updates while moving or actively using app', + ), + value: _fastLocationUpdatesEnabled, + onChanged: _setFastLocationUpdatesEnabled, + ), + ListTile( + dense: true, + leading: const Icon(Icons.straighten, size: 20), + title: Text(l10n.movementThreshold), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_fastLocationMovementThresholdMeters.toStringAsFixed(0)} m', + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: _editFastLocationMovementThreshold, + ), + ListTile( + dense: true, + leading: const Icon(Icons.timer, size: 20), + title: Text(l10n.activeuseUpdateInterval), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_fastLocationActiveCadenceSeconds}s', + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: _editFastLocationActiveCadence, + ), + ListTile( + dense: true, + leading: const Icon(Icons.forum, size: 20), + title: Text( + AppLocalizations.of(context)!.fastGpsTargetChannel, + ), + subtitle: Text( + _describeFastLocationChannel( + context.watch().channels, + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: _editFastLocationChannel, + ), + if (_fastGpsRegionState != null) + ListTile( + dense: true, + leading: const Icon(Icons.travel_explore, size: 20), + title: const Text('Region scope'), + subtitle: Text(_fastGpsRegionState!.selectedLabel), + trailing: const Icon(Icons.chevron_right), + onTap: _editFastGpsRegion, + ), + ListTile( + dense: true, + leading: const Icon(Icons.send, size: 20), + title: Text(AppLocalizations.of(context)!.testSendUpdate), + trailing: const Icon(Icons.chevron_right), + onTap: _sendTestFastLocationUpdate, + ), + const Divider(height: 1), + ListTile( + dense: true, + leading: const Icon(Icons.location_on, size: 20), + title: Text(l10n.locationPermission), + subtitle: FutureBuilder( + future: Geolocator.checkPermission(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Text(l10n.checking); + } + final permission = snapshot.data!; + String statusText; + Color statusColor; + + switch (permission) { + case LocationPermission.always: + statusText = l10n.locationPermissionGrantedAlways; + statusColor = Colors.green; + break; + case LocationPermission.whileInUse: + statusText = l10n.locationPermissionGrantedWhileInUse; + statusColor = Colors.green; + break; + case LocationPermission.denied: + statusText = + l10n.locationPermissionDeniedTapToRequest; + statusColor = Colors.orange; + break; + case LocationPermission.deniedForever: + statusText = l10n + .locationPermissionPermanentlyDeniedOpenSettings; + statusColor = Colors.red; + break; + default: + statusText = l10n.unknown; + statusColor = Colors.grey; + } + + return Text( + statusText, + style: TextStyle(color: statusColor), + ); + }, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _handleLocationPermissionTap(), + ), + ], + ), + const SizedBox(height: 12), + + // ── Voice ── + Consumer2( + builder: (context, appProvider, connectionProvider, child) => + _buildVoiceStatsCard( + bitrate: _voiceBitrate, + connectionProvider: connectionProvider, + bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, + compressorEnabled: appProvider.isVoiceCompressorEnabled, + limiterEnabled: appProvider.isVoiceLimiterEnabled, + autoGainEnabled: appProvider.isVoiceAutoGainEnabled, + echoCancellationEnabled: + appProvider.isVoiceEchoCancellationEnabled, + noiseSuppressionEnabled: + appProvider.isVoiceNoiseSuppressionEnabled, + silenceTrimEnabled: + appProvider.isVoiceSilenceTrimmingEnabled, + ), + ), + _buildSection( + icon: Icons.mic_rounded, + title: AppLocalizations.of(context)!.voice, + subtitle: AppLocalizations.of( + context, + )!.codecBitrateAndAudioProcessing, + children: [ + ListTile( + dense: true, + leading: const Icon(Icons.graphic_eq, size: 20), + title: Text(l10n.voiceBitrate), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _voiceBitrateSubtitle(_voiceBitrate), + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: _showVoiceBitrateDialog, + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.tune, size: 20), + title: Text(l10n.bandpassFilterVoice), + value: appProvider.isVoiceBandPassFilterEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceBandPassFilterEnabled(value); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.compress, size: 20), + title: Text(l10n.voiceCompressor), + value: appProvider.isVoiceCompressorEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceCompressorEnabled(value); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.speed, size: 20), + title: Text(l10n.voiceLimiter), + value: appProvider.isVoiceLimiterEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceLimiterEnabled(value); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.auto_fix_high, size: 20), + title: Text(l10n.micAutoGain), + value: appProvider.isVoiceAutoGainEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceAutoGainEnabled(value); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.hearing_disabled, size: 20), + title: Text(l10n.echoCancellation), + value: appProvider.isVoiceEchoCancellationEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceEchoCancellationEnabled( + value, + ); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.noise_control_off, size: 20), + title: Text(l10n.noiseSuppression), + value: appProvider.isVoiceNoiseSuppressionEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceNoiseSuppressionEnabled( + value, + ); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + dense: true, + secondary: const Icon(Icons.content_cut, size: 20), + title: Text(l10n.trimSilenceInVoiceMessages), + value: appProvider.isVoiceSilenceTrimmingEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceSilenceTrimmingEnabled( + value, + ); + }, + ), + ), + ], + ), + const SizedBox(height: 12), + + // ── Images ── + _buildSection( + icon: Icons.image_rounded, + title: AppLocalizations.of(context)!.images, + subtitle: AppLocalizations.of(context)!.sizeCompressionAndPreview, + children: [ + ListTile( + dense: true, + leading: const Icon(Icons.photo_size_select_large, size: 20), + title: Text(l10n.maxImageSize), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$_imageMaxSize×$_imageMaxSize px', + style: Theme.of(context).textTheme.bodySmall, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: _showImageMaxSizeDialog, + ), + ListTile( + dense: true, + leading: const Icon(Icons.tune, size: 20), + title: Text(l10n.imageCompression), + subtitle: Slider( + value: _imageCompression.toDouble(), + min: 10, + max: 90, + divisions: 8, + label: '$_imageCompression', + onChanged: (v) => + setState(() => _imageCompression = v.round()), + onChangeEnd: (v) => _saveImageCompression(v.round()), + ), + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.invert_colors, size: 20), + title: Text(l10n.grayscale), + subtitle: Text(AppLocalizations.of(context)!.smallerFileSize), + value: _imageGrayscale, + onChanged: (value) async { + await ImagePreferences.setGrayscale(value); + setState(() => _imageGrayscale = value); + await _refreshImageModePreview(); + }, + ), + SwitchListTile( + dense: true, + secondary: const Icon(Icons.compress, size: 20), + title: Text(l10n.ultraMode), + subtitle: Text( + AppLocalizations.of( + context, + )!.extraAggressiveAvifCompression, + ), + value: _imageUltraMode, + onChanged: (value) async { + await ImagePreferences.setUltraMode(value); + setState(() => _imageUltraMode = value); + await _refreshImageModePreview(); + }, + ), + ], + ), + Consumer( + builder: (context, connectionProvider, child) => + _buildImageModePreviewCard(connectionProvider), + ), + const SizedBox(height: 12), + + // ── Profiles ── + _buildSection( + icon: Icons.layers_outlined, + title: AppLocalizations.of(context)!.profiles, + subtitle: AppLocalizations.of( + context, + )!.multiDeviceWorkspaceManagement, + children: [ + SwitchListTile( + dense: true, + secondary: const Icon(Icons.layers_outlined, size: 20), + title: Text(l10n.enableProfiles), + value: _profilesEnabled, + onChanged: (value) async { + await context + .read() + .setProfilesEnabled(value); + if (!mounted) return; + setState(() => _profilesEnabled = value); + }, + ), + if (_profilesEnabled) + ListTile( + dense: true, + leading: const Icon(Icons.folder_copy_outlined, size: 20), + title: Text(l10n.manageProfiles), + subtitle: Text( + context.watch().activeProfileId == + ConfigProfile.defaultProfileId + ? 'Default is active' + : 'Custom profile active', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ProfilesScreen(), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 12), + + // ── Help ── + _buildSection( + icon: Icons.help_outline_rounded, + title: AppLocalizations.of(context)!.help, + subtitle: AppLocalizations.of(context)!.templatesAndTutorials, + children: [ + ListTile( + dense: true, + leading: const Icon(Icons.location_searching, size: 20), + title: Text(l10n.sarTemplates), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const SarTemplateManagementScreen(), + ), + ); + }, + ), + ListTile( + dense: true, + leading: const Icon(Icons.school, size: 20), + title: Text(l10n.viewWelcomeTutorial), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => WelcomeWizardScreen( + onCompleted: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 12), + + // ── About ── + _buildSection( + icon: Icons.info_outline_rounded, + title: l10n.about, + children: [ + ListTile( + dense: true, + leading: const Icon(Icons.info_outline, size: 20), + title: Text(l10n.appVersion), + subtitle: Text( + _packageInfo != null + ? '${_packageInfo!.version} (${_packageInfo!.buildNumber})' + : 'Loading...', + ), + onTap: _handleVersionTap, + ), + ListTile( + dense: true, + leading: const Icon(Icons.description_outlined, size: 20), + title: Text(l10n.aboutMeshCoreSar), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showAboutDialog(), + ), + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + ListTile( + dense: true, + leading: _isCheckingForUpdates + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.system_update, size: 20), + title: Text( + _isCheckingForUpdates + ? 'Checking...' + : 'Check for Updates', + ), + onTap: _isCheckingForUpdates ? null : _checkForUpdates, + ), + ], + ), + const SizedBox(height: 12), + + // ── Data & Developer ── + _buildSection( + icon: Icons.storage_rounded, + title: AppLocalizations.of(context)!.data, + subtitle: AppLocalizations.of( + context, + )!.trafficStatsMessageHistoryAndDeveloperTools, + children: [ + Consumer( + builder: (context, appProvider, child) => ListenableBuilder( + listenable: appProvider.trafficStatsReportingService, + builder: (context, child) => TrafficStatsReportingSection( + service: appProvider.trafficStatsReportingService, + ), + ), + ), + ListTile( + dense: true, + leading: const Icon(Icons.delete_sweep, size: 20), + title: const Text( + 'Clear Messages', + style: TextStyle(color: Colors.red), + ), + subtitle: Text(l10n.deleteAllStoredMessageHistory), + onTap: _clearMessages, + ), + if (_isDeveloperModeEnabled) ...[ + const Divider(height: 1), + ListTile( + dense: true, + leading: const Icon(Icons.bug_report, size: 20), + title: Text(l10n.packageName), + subtitle: Text( + _packageInfo?.packageName ?? 'com.meshcore.sar', + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + l10n.sampleData, + style: Theme.of(context).textTheme.labelMedium, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Text( + l10n.sampleDataDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isLoadingSampleData + ? null + : _loadSampleData, + icon: _isLoadingSampleData + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.add_circle_outline), + label: Text(l10n.loadSampleData), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _isLoadingSampleData + ? null + : _clearSampleData, + icon: const Icon(Icons.delete_outline), + label: Text(l10n.clearAllData), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + ], + ), + ), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _buildSection({ + required IconData icon, + required String title, + String? subtitle, + required List children, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + color: colorScheme.surfaceContainerLow, + elevation: 0, + surfaceTintColor: Colors.transparent, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: colorScheme.primary, size: 22), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + if (subtitle != null) + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + ...children, + ], + ), + ), + ); + } + + Widget _buildCompactToggleRow({ + required IconData icon, + required String label, + required bool value, + required ValueChanged? onChanged, + }) { + return SwitchListTile( + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + secondary: Icon(icon, size: 18), + title: Text(label), + value: value, + onChanged: onChanged, + ); + } + + Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) { + final sourceBytes = _previewSourceBytes; + final fileName = _previewSourceName ?? 'No image selected'; + final radioBw = connectionProvider.deviceInfo.radioBw; + final radioSf = connectionProvider.deviceInfo.radioSf; + final radioCr = connectionProvider.deviceInfo.radioCr; + final bwHz = _resolveBandwidthHz(radioBw); + final previewSizeBytes = _previewCompressedBytes?.length ?? 0; + final directChunk = safeImageDataBytesForPath(0); + final twoHopChunk = safeImageDataBytesForPath(2); + final directFragments = previewSizeBytes > 0 + ? (previewSizeBytes + directChunk - 1) ~/ directChunk + : 0; + final twoHopFragments = previewSizeBytes > 0 + ? (previewSizeBytes + twoHopChunk - 1) ~/ twoHopChunk + : 0; + final imageDirect = estimateImageTransmitDuration( + fragmentCount: directFragments, + sizeBytes: previewSizeBytes, + pathLen: 0, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + final imageTwoHop = estimateImageTransmitDuration( + fragmentCount: twoHopFragments, + sizeBytes: previewSizeBytes, + pathLen: 2, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + + return Card( + margin: const EdgeInsets.only(top: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.photo_library_outlined, size: 20), + SizedBox(width: 8), + Text( + 'Image mode preview', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: Text( + 'Source image', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + FilledButton.icon( + onPressed: _isPreviewLoading + ? null + : _selectPreviewImageFromGallery, + icon: Icon(Icons.photo_library_outlined), + label: Text(AppLocalizations.of(context)!.selectFromGallery), + ), + ], + ), + const SizedBox(height: 6), + Text( + fileName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.8), + ), + ), + if (previewSizeBytes > 0) ...[ + const SizedBox(height: 4), + Text( + 'Preview tx (this image): Direct ${_formatEstimateDuration(imageDirect)} • 2-hop ${_formatEstimateDuration(imageTwoHop)}', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + 'Radio BW ${_formatBandwidthLabel(bwHz)} · SF ${radioSf ?? 10} · CR ${radioCr ?? 5}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + _showCurrentImagePreview + ? 'Showing: current image mode' + : 'Showing: source image', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Switch( + value: _showCurrentImagePreview, + onChanged: (value) { + setState(() => _showCurrentImagePreview = value); + }, + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AspectRatio( + aspectRatio: 1, + child: _showCurrentImagePreview + ? (_isPreviewLoading + ? const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ) + : (_previewCompressedBytes != null + ? AvifImage.memory( + _previewCompressedBytes!, + fit: BoxFit.cover, + ) + : Container(color: Colors.black12))) + : (sourceBytes != null + ? Image.memory(sourceBytes, fit: BoxFit.cover) + : Container(color: Colors.black12)), + ), + ), + ], + ), + ), + ); + } + + String _formatEstimateDuration(Duration value) { + if (value.inSeconds < 60) return '~${value.inSeconds}s'; + return '~${value.inMinutes}m ${value.inSeconds % 60}s'; + } + + static String _formatBandwidthLabel(int bwHz) { + if (bwHz >= 1000000) return '${(bwHz / 1000000).toStringAsFixed(2)} MHz'; + if (bwHz >= 1000) return '${(bwHz / 1000).toStringAsFixed(1)} kHz'; + return '$bwHz Hz'; + } + + int _resolveBandwidthHz(int? rawBw) { + if (rawBw == null) return 250000; + if (rawBw > 1000) return rawBw; + switch (rawBw) { + case 0: + return 7800; + case 1: + return 10400; + case 2: + return 15600; + case 3: + return 20800; + case 4: + return 31250; + case 5: + return 41700; + case 6: + return 62500; + case 7: + return 125000; + case 8: + return 250000; + case 9: + return 500000; + default: + return 250000; + } + } + + Widget _buildVoiceStatsCard({ + required int bitrate, + required ConnectionProvider connectionProvider, + required bool bandPassEnabled, + required bool compressorEnabled, + required bool limiterEnabled, + required bool autoGainEnabled, + required bool echoCancellationEnabled, + required bool noiseSuppressionEnabled, + required bool silenceTrimEnabled, + }) { + final supported = VoiceBitratePreferences.supportedBitrates; + final minBitrate = supported.reduce((a, b) => a < b ? a : b).toDouble(); + final maxBitrate = supported.reduce((a, b) => a > b ? a : b).toDouble(); + final normalized = maxBitrate > minBitrate + ? ((bitrate - minBitrate) / (maxBitrate - minBitrate)).clamp(0.0, 1.0) + : 1.0; + final enabledCount = + (bandPassEnabled ? 1 : 0) + + (compressorEnabled ? 1 : 0) + + (limiterEnabled ? 1 : 0) + + (autoGainEnabled ? 1 : 0) + + (echoCancellationEnabled ? 1 : 0) + + (noiseSuppressionEnabled ? 1 : 0) + + (silenceTrimEnabled ? 1 : 0); + final radioBw = connectionProvider.deviceInfo.radioBw; + final radioSf = connectionProvider.deviceInfo.radioSf; + final radioCr = connectionProvider.deviceInfo.radioCr; + final bwHz = _resolveBandwidthHz(radioBw); + final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate); + const voicePreviewMs = 10000; // 10-second reference clip + final packetDurationMs = voiceMode.packetDurationMs; + final voicePacketCount = + (voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs; + final voiceDirect = estimateVoiceTransmitDuration( + mode: voiceMode, + packetCount: voicePacketCount, + durationMs: voicePreviewMs, + pathLen: 0, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + final voiceTwoHop = estimateVoiceTransmitDuration( + mode: voiceMode, + packetCount: voicePacketCount, + durationMs: voicePreviewMs, + pathLen: 2, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + + return Card( + margin: const EdgeInsets.only(bottom: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Voice Processing Stats', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Text( + 'Codec: ${voiceMode.label} · $bitrate bps', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text( + 'Preview tx (10s ${voiceMode.label}): Direct ${_formatEstimateDuration(voiceDirect)} • 2-hop ${_formatEstimateDuration(voiceTwoHop)}', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + 'Radio BW ${_formatBandwidthLabel(bwHz)} · SF ${radioSf ?? 10} · CR ${radioCr ?? 5}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator(value: normalized, minHeight: 8), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _voiceStatChip( + label: AppLocalizations.of(context)!.bandpass, + enabled: bandPassEnabled, + ), + ), + SizedBox(width: 8), + Expanded( + child: _voiceStatChip( + label: AppLocalizations.of(context)!.compressor, + enabled: compressorEnabled, + ), + ), + SizedBox(width: 8), + Expanded( + child: _voiceStatChip( + label: AppLocalizations.of(context)!.limiter, + enabled: limiterEnabled, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _voiceStatChip( + label: AppLocalizations.of(context)!.autoGain, + enabled: autoGainEnabled, + ), + ), + SizedBox(width: 8), + Expanded( + child: _voiceStatChip( + label: AppLocalizations.of(context)!.echoCancel, + enabled: echoCancellationEnabled, + ), + ), + SizedBox(width: 8), + Expanded( + child: _voiceStatChip( + label: AppLocalizations.of(context)!.noiseSuppress, + enabled: noiseSuppressionEnabled, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _voiceStatChip( + label: AppLocalizations.of(context)!.silenceTrim, + enabled: silenceTrimEnabled, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Processing enabled: $enabledCount/7', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + } + + Widget _voiceStatChip({required String label, required bool enabled}) { + final color = enabled ? Colors.green : Colors.grey; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + enabled ? Icons.check_circle : Icons.radio_button_unchecked, + size: 16, + color: color, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + + void _showThemeDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.chooseTheme), + content: SingleChildScrollView( + child: RadioGroup( + groupValue: _selectedTheme, + onChanged: (value) { + _handleThemeChange(value); + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + title: Text(AppLocalizations.of(context)!.light), + subtitle: Text(AppLocalizations.of(context)!.blueLightTheme), + value: AppThemeMode.light, + ), + RadioListTile( + title: Text(AppLocalizations.of(context)!.dark), + subtitle: Text(AppLocalizations.of(context)!.blueDarkTheme), + value: AppThemeMode.dark, + ), + Divider(), + RadioListTile( + title: Row( + children: [ + Text(AppLocalizations.of(context)!.sarRed), + const SizedBox(width: 8), + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: const Color(0xFFFF5252), + shape: BoxShape.circle, + border: Border.all(color: Colors.black26), + ), + ), + ], + ), + subtitle: Text( + AppLocalizations.of(context)!.alertEmergencyMode, + ), + value: AppThemeMode.sarRed, + ), + RadioListTile( + title: Row( + children: [ + Text(AppLocalizations.of(context)!.sarGreen), + const SizedBox(width: 8), + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: const Color(0xFF69F0AE), + shape: BoxShape.circle, + border: Border.all(color: Colors.black26), + ), + ), + ], + ), + subtitle: Text( + AppLocalizations.of(context)!.safeAllClearMode, + ), + value: AppThemeMode.sarGreen, + ), + RadioListTile( + title: Row( + children: [ + Text(AppLocalizations.of(context)!.sarNavyBlue), + const SizedBox(width: 8), + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: const Color(0xFF5C9FFF), + shape: BoxShape.circle, + border: Border.all(color: Colors.black26), + ), + ), + ], + ), + subtitle: Text( + AppLocalizations.of(context)!.sarNavyBlueDescription, + ), + value: AppThemeMode.sarNavyBlue, + ), + Divider(), + RadioListTile( + title: Text(AppLocalizations.of(context)!.autoSystem), + subtitle: Text( + AppLocalizations.of(context)!.followSystemTheme, + ), + value: AppThemeMode.system, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + ], + ), + ); + } + + void _showLanguageDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.chooseLanguage), + content: SingleChildScrollView( + child: RadioGroup( + groupValue: _selectedLocale, + onChanged: (value) { + _handleLocaleChange(value); + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + title: Text(LocalePreferences.getDisplayName(null)), + subtitle: Text(LocalePreferences.getDisplayName(null)), + value: null, + ), + const Divider(), + ...LocalePreferences.supportedLocales.map((locale) { + return RadioListTile( + title: Text(LocalePreferences.getNativeDisplayName(locale)), + value: locale, + ); + }), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + ], + ), + ); + } + + void _showImageMaxSizeDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.maxImageSize), + content: SingleChildScrollView( + child: RadioGroup( + groupValue: _imageMaxSize, + onChanged: (value) { + if (value != null) _saveImageMaxSize(value); + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: ImagePreferences.supportedSizes + .map( + (size) => RadioListTile( + value: size, + title: Text('$size×$size px'), + subtitle: size == ImagePreferences.defaultMaxSize + ? Text(AppLocalizations.of(context)!.defaultValue) + : null, + ), + ) + .toList(), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + ], + ), + ); + } + + void _showVoiceBitrateDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.voiceBitrate), + content: SingleChildScrollView( + child: RadioGroup( + groupValue: _voiceBitrate, + onChanged: (value) { + if (value != null) { + _saveVoiceBitratePreference(value); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: VoiceBitratePreferences.supportedBitrates + .map( + (bitrate) => RadioListTile( + value: bitrate, + title: Text('$bitrate bps'), + subtitle: + bitrate == VoiceBitratePreferences.defaultBitrate + ? Text(AppLocalizations.of(context)!.defaultValue) + : null, + ), + ) + .toList(), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + ], + ), + ); + } + + void _showAboutDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'MeshCore SAR', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + 'Version ${_packageInfo?.version ?? '1.0.0'}', + style: Theme.of(context).textTheme.bodyMedium, + ), + SizedBox(height: 16), + Text(AppLocalizations.of(context)!.aboutDescription), + SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.technologiesUsed, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text(AppLocalizations.of(context)!.technologiesList), + ], + ), + ), + actions: [ + TextButton.icon( + onPressed: () async { + final url = Uri.parse('https://dz0ny.dev/posts/meshcore-sar/'); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + }, + icon: Icon(Icons.open_in_new), + label: Text(AppLocalizations.of(context)!.moreInfo), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.close), + ), + ], + ), + ); + } +} diff --git a/lib/screens/welcome_wizard_screen.dart b/lib/screens/welcome_wizard_screen.dart new file mode 100644 index 0000000..68b222a --- /dev/null +++ b/lib/screens/welcome_wizard_screen.dart @@ -0,0 +1,1007 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../l10n/app_localizations.dart'; +import '../models/device_info.dart'; +import '../providers/connection_provider.dart'; +import '../services/wizard_preferences.dart'; +import '../widgets/connection_dialog.dart'; + +/// Welcome wizard screen to introduce new users to the app +class WelcomeWizardScreen extends StatefulWidget { + final VoidCallback? onCompleted; + + const WelcomeWizardScreen({super.key, this.onCompleted}); + + @override + State createState() => _WelcomeWizardScreenState(); +} + +class _WelcomeWizardScreenState extends State { + final PageController _pageController = PageController(); + final TextEditingController _deviceNameController = TextEditingController(); + int _currentPage = 0; + bool _isApplyingDeviceSetup = false; + static const int _totalPages = 6; + static const List<_RadioPreset> _radioPresets = [ + // Official MeshCore config presets from https://api.meshcore.nz/api/v1/config + _RadioPreset( + id: 'australia', + label: 'Australia', + summary: '915.800 MHz, BW 250 kHz, SF10, CR5', + frequencyKhz: 915800, + bandwidth: 8, + spreadingFactor: 10, + codingRate: 5, + ), + _RadioPreset( + id: 'australia_narrow', + label: 'Australia (Narrow)', + summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8', + frequencyKhz: 916575, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 8, + ), + _RadioPreset( + id: 'australia_sa_wa', + label: 'Australia: SA, WA', + summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 923125, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'australia_qld', + label: 'Australia: QLD', + summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5', + frequencyKhz: 923125, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 5, + ), + _RadioPreset( + id: 'eu_uk_narrow', + label: 'EU/UK (Narrow)', + summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'eu_uk_deprecated', + label: 'EU/UK (Deprecated)', + summary: '869.525 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 869525, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'czech_republic_narrow', + label: 'Czech Republic (Narrow)', + summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 869432, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'eu_433_long_range', + label: 'EU 433MHz (Long Range)', + summary: '433.650 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 433650, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'new_zealand', + label: 'New Zealand', + summary: '917.375 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 917375, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'new_zealand_narrow', + label: 'New Zealand (Narrow)', + summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 917375, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'portugal_433', + label: 'Portugal 433', + summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6', + frequencyKhz: 433375, + bandwidth: 6, + spreadingFactor: 9, + codingRate: 6, + ), + _RadioPreset( + id: 'portugal_868', + label: 'Portugal 868', + summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 6, + ), + _RadioPreset( + id: 'switzerland', + label: 'Switzerland', + summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'usa_canada_recommended', + label: 'USA/Canada (Recommended)', + summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 910525, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'vietnam_narrow', + label: 'Vietnam (Narrow)', + summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5', + frequencyKhz: 920250, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 5, + ), + _RadioPreset( + id: 'vietnam_deprecated', + label: 'Vietnam (Deprecated)', + summary: '920.250 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 920250, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + ]; + /// Null means "keep the device's current radio settings" (no preset write). + _RadioPreset? _selectedPreset; + bool _userSelectedPreset = false; + + @override + void initState() { + super.initState(); + final deviceInfo = context.read().deviceInfo; + _deviceNameController.text = + deviceInfo.selfName ?? deviceInfo.deviceName ?? ''; + _selectedPreset = _matchRadioPreset(deviceInfo); + } + + static const Map _bandwidthHzToValue = { + 7800: 0, + 10400: 1, + 15600: 2, + 20800: 3, + 31250: 4, + 41700: 5, + 62500: 6, + 125000: 7, + 250000: 8, + 500000: 9, + }; + + static int? _normalizeBandwidthValue(int? bw) { + if (bw == null) { + return null; + } + if (bw >= 0 && bw <= 9) { + return bw; + } + return _bandwidthHzToValue[bw]; + } + + static int? _normalizeSpreadingFactor(int? spreadingFactor) { + if (spreadingFactor == null || + spreadingFactor < 7 || + spreadingFactor > 12) { + return null; + } + return spreadingFactor; + } + + static int? _normalizeCodingRate(int? codingRate) { + if (codingRate == null) { + return null; + } + if (codingRate >= 5 && codingRate <= 8) { + return codingRate; + } + if (codingRate >= 1 && codingRate <= 4) { + return codingRate + 4; + } + return null; + } + + /// Find the preset matching the device's current radio settings, if any. + static _RadioPreset? _matchRadioPreset(DeviceInfo deviceInfo) { + final frequencyKhz = deviceInfo.radioFreq; + final bandwidth = _normalizeBandwidthValue(deviceInfo.radioBw); + final spreadingFactor = _normalizeSpreadingFactor(deviceInfo.radioSf); + final codingRate = _normalizeCodingRate(deviceInfo.radioCr); + + if (frequencyKhz == null || + bandwidth == null || + spreadingFactor == null || + codingRate == null) { + return null; + } + + for (final preset in _radioPresets) { + if (preset.frequencyKhz == frequencyKhz && + preset.bandwidth == bandwidth && + preset.spreadingFactor == spreadingFactor && + preset.codingRate == codingRate) { + return preset; + } + } + return null; + } + + @override + void dispose() { + _pageController.dispose(); + _deviceNameController.dispose(); + super.dispose(); + } + + void _onPageChanged(int page) { + setState(() { + _currentPage = page; + }); + } + + void _nextPage() { + if (_currentPage == 1) { + _handleDeviceSetupAction(); + } else if (_currentPage < _totalPages - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } else { + _completeWizard(); + } + } + + void _previousPage() { + if (_currentPage > 0) { + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + Future _completeWizard() async { + await WizardPreferences.setWizardCompleted(true); + if (mounted) { + widget.onCompleted?.call(); + } + } + + Future _openConnectionDialog() async { + final connected = await showConnectionDialogFlow( + context, + offerPostConnectRepeaterDiscovery: true, + ); + if (!mounted) return; + final connectionProvider = context.read(); + if (connected) { + await connectionProvider.refreshDeviceInfo(); + } + if (!mounted) return; + final deviceInfo = connectionProvider.deviceInfo; + final fetchedName = deviceInfo.selfName ?? deviceInfo.deviceName ?? ''; + setState(() { + if (fetchedName.isNotEmpty) { + _deviceNameController.text = fetchedName; + } + if (!_userSelectedPreset) { + _selectedPreset = _matchRadioPreset(deviceInfo); + } + }); + } + + Future _handleDeviceSetupAction() async { + final l10n = AppLocalizations.of(context)!; + final connectionProvider = context.read(); + final deviceInfo = connectionProvider.deviceInfo; + if (!deviceInfo.isConnected) { + await _openConnectionDialog(); + return; + } + + final trimmedName = _deviceNameController.text.trim(); + if (trimmedName.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.wizardEnterDeviceName))); + return; + } + + setState(() { + _isApplyingDeviceSetup = true; + }); + + final selectedPreset = _selectedPreset; + + try { + connectionProvider.clearError(); + await connectionProvider.setAdvertName(trimmedName); + var saveError = connectionProvider.error; + + // Only write radio params when the user picked a preset that differs + // from the device's current settings. + if (saveError == null && + selectedPreset != null && + selectedPreset != _matchRadioPreset(deviceInfo)) { + await connectionProvider.setRadioParams( + frequency: selectedPreset.frequencyKhz, + bandwidth: selectedPreset.bandwidth, + spreadingFactor: selectedPreset.spreadingFactor, + codingRate: selectedPreset.codingRate, + ); + saveError = connectionProvider.error; + } + + if (saveError != null) { + connectionProvider.clearError(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.failedToSave(saveError)), + backgroundColor: Colors.red, + ), + ); + return; + } + + await connectionProvider.refreshDeviceInfo(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.wizardDeviceSetupSaved( + trimmedName, + selectedPreset?.label ?? 'current radio settings', + ), + ), + backgroundColor: Colors.green, + ), + ); + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.failedToSave(e.toString())), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() { + _isApplyingDeviceSetup = false; + }); + } + } + } + + void _skipDeviceSetupStep() { + if (_isApplyingDeviceSetup) return; + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + // Top bar with skip button + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (_currentPage > 0) + TextButton.icon( + onPressed: _previousPage, + icon: Icon(Icons.arrow_back), + label: Text(l10n.wizardBack), + ) + else + const SizedBox(width: 80), + if (_currentPage < _totalPages - 1) + TextButton( + onPressed: _completeWizard, + child: Text(l10n.wizardSkip), + ) + else + const SizedBox(width: 80), + ], + ), + ), + + // Page view with wizard content + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), + children: [ + _buildWelcomePage(context, l10n, colorScheme), + _buildConnectDevicePage(context, l10n, colorScheme), + _buildConnectingPage(context, l10n, colorScheme), + _buildChannelPage(context, l10n, colorScheme), + _buildContactsPage(context, l10n, colorScheme), + _buildMapPage(context, l10n, colorScheme), + ], + ), + ), + + // Page indicators + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + _totalPages, + (index) => Container( + margin: const EdgeInsets.symmetric(horizontal: 4.0), + width: _currentPage == index ? 12.0 : 8.0, + height: _currentPage == index ? 12.0 : 8.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _currentPage == index + ? colorScheme.primary + : colorScheme.outline.withValues(alpha: 0.3), + ), + ), + ), + ), + ), + + // Next/Get Started button + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isApplyingDeviceSetup ? null : _nextPage, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + _currentPage == 1 + ? _deviceSetupButtonLabel(context) + : _currentPage < _totalPages - 1 + ? l10n.wizardNext + : l10n.wizardGetStarted, + style: const TextStyle(fontSize: 16), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildWelcomePage( + BuildContext context, + AppLocalizations l10n, + ColorScheme colorScheme, + ) { + return _buildPage( + icon: Icons.waving_hand, + iconColor: Colors.orange, + title: l10n.wizardWelcomeTitle, + description: l10n.wizardOverviewDescription, + features: [ + _FeatureItem( + icon: Icons.chat_bubble_outline, + text: l10n.wizardOverviewFeature1, + ), + _FeatureItem( + icon: Icons.emergency_share, + text: l10n.wizardOverviewFeature2, + ), + _FeatureItem( + icon: Icons.settings_input_antenna, + text: l10n.wizardOverviewFeature3, + ), + ], + colorScheme: colorScheme, + ); + } + + Widget _buildConnectingPage( + BuildContext context, + AppLocalizations l10n, + ColorScheme colorScheme, + ) { + return _buildPage( + icon: Icons.forum_rounded, + iconColor: Colors.blue, + title: l10n.wizardMessagingTitle, + description: l10n.wizardMessagingDescription, + features: [ + _FeatureItem(icon: Icons.chat, text: l10n.wizardMessagingFeature1), + _FeatureItem(icon: Icons.campaign, text: l10n.wizardMessagingFeature2), + _FeatureItem(icon: Icons.mic, text: l10n.wizardMessagingFeature3), + ], + colorScheme: colorScheme, + ); + } + + Widget _buildConnectDevicePage( + BuildContext context, + AppLocalizations l10n, + ColorScheme colorScheme, + ) { + final connectionProvider = context.watch(); + final deviceInfo = connectionProvider.deviceInfo; + final isConnected = deviceInfo.isConnected; + final connectedName = deviceInfo.selfName ?? deviceInfo.deviceName; + + return LayoutBuilder( + builder: (context, constraints) => Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 600, + maxHeight: constraints.maxHeight, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPageHeader( + context: context, + colorScheme: colorScheme, + icon: Icons.bluetooth_connected, + iconColor: Colors.green, + title: l10n.wizardConnectDeviceTitle, + description: l10n.wizardConnectDeviceDescription, + badge: l10n.wizardSetupBadge, + ), + const SizedBox(height: 16), + _buildContentCard( + colorScheme: colorScheme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + isConnected + ? Icons.check_circle + : Icons.bluetooth_searching, + color: isConnected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + SizedBox(width: 12), + Expanded( + child: Text( + isConnected + ? l10n.wizardConnectedToDevice( + connectedName ?? l10n.device, + ) + : l10n.wizardNoDeviceConnected, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + TextButton( + onPressed: _openConnectionDialog, + child: Text( + isConnected ? l10n.change : l10n.connect, + ), + ), + ], + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _skipDeviceSetupStep, + child: Text(l10n.wizardSkipForNow), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + _buildContentCard( + colorScheme: colorScheme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _deviceNameController, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: l10n.wizardDeviceNameLabel, + border: OutlineInputBorder(), + helperText: l10n.wizardDeviceNameHelp, + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField<_RadioPreset?>( + key: ValueKey(_selectedPreset?.id ?? 'keep_current'), + initialValue: _selectedPreset, + isExpanded: true, + decoration: InputDecoration( + labelText: l10n.wizardConfigRegionLabel, + border: OutlineInputBorder(), + helperText: l10n.wizardConfigRegionHelp, + ), + items: [ + const DropdownMenuItem<_RadioPreset?>( + value: null, + child: Text( + 'Keep current settings', + overflow: TextOverflow.ellipsis, + ), + ), + ..._radioPresets.map( + (preset) => DropdownMenuItem<_RadioPreset?>( + value: preset, + child: Text( + preset.label, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onChanged: (preset) { + setState(() { + _selectedPreset = preset; + _userSelectedPreset = true; + }); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + _buildContentCard( + colorScheme: colorScheme, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _selectedPreset?.label ?? 'Keep current settings', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + _selectedPreset?.summary ?? + 'Saving will not change the radio settings on your device.', + style: Theme.of(context).textTheme.bodyMedium, + ), + SizedBox(height: 14), + ...[ + _FeatureItem( + icon: Icons.rule, + text: l10n.wizardPresetNote1, + ), + _FeatureItem( + icon: Icons.settings_input_antenna, + text: l10n.wizardPresetNote2, + ), + _FeatureItem( + icon: Icons.public, + text: l10n.wizardPresetNote3, + ), + ].map( + (feature) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: _buildFeatureRow( + context, + colorScheme, + feature, + iconSize: 20, + gap: 12, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + String _deviceSetupButtonLabel(BuildContext context) { + final isConnected = context + .watch() + .deviceInfo + .isConnected; + if (_isApplyingDeviceSetup) { + return AppLocalizations.of(context)!.wizardSaving; + } + final l10n = AppLocalizations.of(context)!; + return isConnected + ? l10n.wizardSaveAndContinue + : l10n.wizardConnectDeviceTitle; + } + + Widget _buildChannelPage( + BuildContext context, + AppLocalizations l10n, + ColorScheme colorScheme, + ) { + return _buildPage( + icon: Icons.groups_rounded, + iconColor: Colors.purple, + title: l10n.wizardNetworkTitle, + description: l10n.wizardNetworkDescription, + features: [ + _FeatureItem( + icon: Icons.person_add_alt_1, + text: l10n.wizardNetworkFeature1, + ), + _FeatureItem(icon: Icons.route, text: l10n.wizardNetworkFeature2), + _FeatureItem(icon: Icons.hub, text: l10n.wizardNetworkFeature3), + ], + colorScheme: colorScheme, + ); + } + + Widget _buildContactsPage( + BuildContext context, + AppLocalizations l10n, + ColorScheme colorScheme, + ) { + return _buildPage( + icon: Icons.map_rounded, + iconColor: Colors.teal, + title: l10n.wizardMapOpsTitle, + description: l10n.wizardMapOpsDescription, + features: [ + _FeatureItem(icon: Icons.location_on, text: l10n.wizardMapOpsFeature1), + _FeatureItem(icon: Icons.draw, text: l10n.wizardMapOpsFeature2), + _FeatureItem(icon: Icons.router, text: l10n.wizardMapOpsFeature3), + ], + colorScheme: colorScheme, + ); + } + + Widget _buildMapPage( + BuildContext context, + AppLocalizations l10n, + ColorScheme colorScheme, + ) { + return _buildPage( + icon: Icons.tune_rounded, + iconColor: Colors.red, + title: l10n.wizardToolsTitle, + description: l10n.wizardToolsDescription, + features: [ + _FeatureItem(icon: Icons.settings, text: l10n.wizardToolsFeature1), + _FeatureItem(icon: Icons.sensors, text: l10n.wizardToolsFeature2), + _FeatureItem( + icon: Icons.analytics_outlined, + text: l10n.wizardToolsFeature3, + ), + ], + colorScheme: colorScheme, + ); + } + + Widget _buildPage({ + required IconData icon, + required Color iconColor, + required String title, + required String description, + List<_FeatureItem>? features, + required ColorScheme colorScheme, + }) { + final l10n = AppLocalizations.of(context)!; + return LayoutBuilder( + builder: (context, constraints) => Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 560, + maxHeight: constraints.maxHeight, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildPageHeader( + context: context, + colorScheme: colorScheme, + icon: icon, + iconColor: iconColor, + title: title, + description: description, + badge: l10n.wizardOverviewBadge, + ), + if (features != null && features.isNotEmpty) ...[ + const SizedBox(height: 18), + _buildContentCard( + colorScheme: colorScheme, + child: Column( + children: features + .map( + (feature) => Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: _buildFeatureRow( + context, + colorScheme, + feature, + ), + ), + ) + .toList(), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildPageHeader({ + required BuildContext context, + required ColorScheme colorScheme, + required IconData icon, + required Color iconColor, + required String title, + required String description, + required String badge, + }) { + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + badge, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 56, color: iconColor), + ), + const SizedBox(height: 18), + Text( + title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Text( + description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.72), + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildContentCard({ + required ColorScheme colorScheme, + required Widget child, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: child, + ); + } + + Widget _buildFeatureRow( + BuildContext context, + ColorScheme colorScheme, + _FeatureItem feature, { + double iconSize = 22, + double gap = 14, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(feature.icon, color: colorScheme.primary, size: iconSize), + SizedBox(width: gap), + Expanded( + child: Text( + feature.text, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface), + ), + ), + ], + ); + } +} + +class _FeatureItem { + final IconData icon; + final String text; + + _FeatureItem({required this.icon, required this.text}); +} + +class _RadioPreset { + final String id; + final String label; + final String summary; + final int frequencyKhz; + final int bandwidth; + final int spreadingFactor; + final int codingRate; + + const _RadioPreset({ + required this.id, + required this.label, + required this.summary, + required this.frequencyKhz, + required this.bandwidth, + required this.spreadingFactor, + required this.codingRate, + }); +} diff --git a/lib/services/app_config_snapshot_service.dart b/lib/services/app_config_snapshot_service.dart new file mode 100644 index 0000000..c9f3f21 --- /dev/null +++ b/lib/services/app_config_snapshot_service.dart @@ -0,0 +1,150 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/config_profile.dart'; +import '../providers/app_provider.dart'; +import 'image_preferences.dart'; +import 'profiles_feature_service.dart'; +import 'route_hash_preferences.dart'; +import 'voice_bitrate_preferences.dart'; + +class AppConfigSnapshotService { + Future capture(AppProvider appProvider) async { + final prefs = await SharedPreferences.getInstance(); + final locationTracking = appProvider.locationTrackingService; + return AppSettingsProfileSection( + mapEnabled: appProvider.isMapEnabled, + contactsEnabled: appProvider.isContactsEnabled, + sensorsEnabled: appProvider.isSensorsEnabled, + voiceSilenceTrimmingEnabled: appProvider.isVoiceSilenceTrimmingEnabled, + voiceBandPassFilterEnabled: appProvider.isVoiceBandPassFilterEnabled, + voiceCompressorEnabled: appProvider.isVoiceCompressorEnabled, + voiceLimiterEnabled: appProvider.isVoiceLimiterEnabled, + voiceAutoGainEnabled: appProvider.isVoiceAutoGainEnabled, + voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled, + voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled, + messageFontScale: appProvider.messageFontScale, + clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, + nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, + voiceBitrate: await VoiceBitratePreferences.getBitrate(), + routeHashSize: await RouteHashPreferences.getHashSize(), + imageMaxSize: await ImagePreferences.getMaxSize(), + imageCompression: await ImagePreferences.getCompression(), + imageGrayscale: await ImagePreferences.getGrayscale(), + imageUltraMode: await ImagePreferences.getUltraMode(), + showRxTxIndicators: + prefs.getBool( + ProfileStorageScope.scopedKey('show_rx_tx_indicators'), + ) ?? + true, + fastLocationUpdatesEnabled: locationTracking.fastLocationUpdatesEnabled, + fastLocationMovementThresholdMeters: + locationTracking.fastLocationMovementThresholdMeters, + fastLocationActiveCadenceSeconds: + locationTracking.fastLocationActiveCadenceSeconds, + ); + } + + Future apply( + AppSettingsProfileSection? section, + AppProvider appProvider, + ) async { + if (section == null || section.isEmpty) { + return; + } + + if (section.mapEnabled != null) { + await appProvider.toggleMapEnabled(section.mapEnabled!); + } + if (section.contactsEnabled != null) { + await appProvider.toggleContactsEnabled(section.contactsEnabled!); + } + if (section.sensorsEnabled != null) { + await appProvider.toggleSensorsEnabled(section.sensorsEnabled!); + } + if (section.voiceSilenceTrimmingEnabled != null) { + await appProvider.toggleVoiceSilenceTrimmingEnabled( + section.voiceSilenceTrimmingEnabled!, + ); + } + if (section.voiceBandPassFilterEnabled != null) { + await appProvider.toggleVoiceBandPassFilterEnabled( + section.voiceBandPassFilterEnabled!, + ); + } + if (section.voiceCompressorEnabled != null) { + await appProvider.toggleVoiceCompressorEnabled( + section.voiceCompressorEnabled!, + ); + } + if (section.voiceLimiterEnabled != null) { + await appProvider.toggleVoiceLimiterEnabled(section.voiceLimiterEnabled!); + } + if (section.voiceAutoGainEnabled != null) { + await appProvider.toggleVoiceAutoGainEnabled( + section.voiceAutoGainEnabled!, + ); + } + if (section.voiceEchoCancellationEnabled != null) { + await appProvider.toggleVoiceEchoCancellationEnabled( + section.voiceEchoCancellationEnabled!, + ); + } + if (section.voiceNoiseSuppressionEnabled != null) { + await appProvider.toggleVoiceNoiseSuppressionEnabled( + section.voiceNoiseSuppressionEnabled!, + ); + } + if (section.messageFontScale != null) { + await appProvider.setMessageFontScale(section.messageFontScale!); + } + if (section.clearPathOnMaxRetry != null) { + await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!); + } + if (section.nearestRelayFallbackEnabled != null) { + await appProvider.toggleNearestRelayFallbackEnabled( + section.nearestRelayFallbackEnabled!, + ); + } + if (section.voiceBitrate != null) { + await VoiceBitratePreferences.setBitrate(section.voiceBitrate!); + } + if (section.routeHashSize != null) { + await RouteHashPreferences.setHashSize(section.routeHashSize!); + } + if (section.imageMaxSize != null) { + await ImagePreferences.setMaxSize(section.imageMaxSize!); + } + if (section.imageCompression != null) { + await ImagePreferences.setCompression(section.imageCompression!); + } + if (section.imageGrayscale != null) { + await ImagePreferences.setGrayscale(section.imageGrayscale!); + } + if (section.imageUltraMode != null) { + await ImagePreferences.setUltraMode(section.imageUltraMode!); + } + if (section.showRxTxIndicators != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + ProfileStorageScope.scopedKey('show_rx_tx_indicators'), + section.showRxTxIndicators!, + ); + } + + final locationTracking = appProvider.locationTrackingService; + if (section.fastLocationUpdatesEnabled != null) { + locationTracking.fastLocationUpdatesEnabled = + section.fastLocationUpdatesEnabled!; + } + if (section.fastLocationMovementThresholdMeters != null) { + locationTracking.fastLocationMovementThresholdMeters = + section.fastLocationMovementThresholdMeters!.clamp(10.0, 1000.0); + } + if (section.fastLocationActiveCadenceSeconds != null) { + locationTracking.fastLocationActiveCadenceSeconds = + section.fastLocationActiveCadenceSeconds!.clamp(10, 31); + } + await locationTracking.saveSettings(); + await appProvider.reloadProfileScopedSettings(); + } +} diff --git a/lib/services/background_location_service.dart b/lib/services/background_location_service.dart new file mode 100644 index 0000000..ddd23d1 --- /dev/null +++ b/lib/services/background_location_service.dart @@ -0,0 +1,334 @@ +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'; +import 'package:meshcore_client/meshcore_client.dart'; +import 'profiles_feature_service.dart'; + +/// Background location tracking service for SAR operations +/// Tracks user location and sends periodic updates via MeshCore BLE +@pragma('vm:entry-point') +class BackgroundLocationService { + static const String _prefKeyEnabled = 'background_tracking_enabled'; + 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); + } + + bool _isInitialized = false; + StreamSubscription? _positionSubscription; + + /// Initialize the service with BLE service reference + void initialize(MeshCoreBleService bleService) { + _bleService = bleService; + _isInitialized = true; + } + + /// Start location tracking and automatic advertisement + /// Returns true if successful, false otherwise + /// + Future startTracking({double distanceThreshold = 10.0}) async { + if (!_isInitialized || _bleService == null) { + debugPrint( + '⚠️ [BackgroundLocation] Service not initialized or BLE service null', + ); + return false; + } + + if (!_bleService!.isConnected) { + debugPrint('⚠️ [BackgroundLocation] BLE not connected'); + return false; + } + + // Check location permissions + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + debugPrint('⚠️ [BackgroundLocation] Location permission denied'); + return false; + } + } + + if (permission == LocationPermission.deniedForever) { + debugPrint( + '⚠️ [BackgroundLocation] Location permission permanently denied', + ); + return false; + } + + if (Platform.isIOS && permission != LocationPermission.always) { + permission = await Geolocator.requestPermission(); + if (permission != LocationPermission.always) { + debugPrint( + '⚠️ [BackgroundLocation] iOS background tracking requires Always permission', + ); + return false; + } + } + + // Save settings + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_scopedKey(_prefKeyEnabled), true); + await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold); + + await _startForegroundService(distanceThreshold); + + // Start listening to position updates + Position? lastPosition; + try { + _positionSubscription = + Geolocator.getPositionStream( + locationSettings: _buildLocationSettings( + distanceFilter: distanceThreshold.toInt(), + ), + ).listen((Position position) async { + debugPrint( + '📍 [BackgroundLocation] New position: ${position.latitude}, ${position.longitude}', + ); + + // Calculate distance from last position + if (lastPosition != null) { + final distance = Geolocator.distanceBetween( + lastPosition!.latitude, + lastPosition!.longitude, + position.latitude, + position.longitude, + ); + + debugPrint( + ' Distance moved: ${distance.toStringAsFixed(1)}m (threshold: ${distanceThreshold}m)', + ); + + // Skip if haven't moved enough + if (distance < distanceThreshold) { + return; + } + } + + // Update last position + lastPosition = position; + + // Save to preferences + await prefs.setDouble( + _scopedKey(_prefKeyLastLat), + position.latitude, + ); + await prefs.setDouble( + _scopedKey(_prefKeyLastLon), + position.longitude, + ); + + // Update device's advertised location + if (_bleService != null && _bleService!.isConnected) { + try { + debugPrint( + '📤 [BackgroundLocation] Updating device location...', + ); + await _bleService!.setAdvertLatLon( + latitude: position.latitude, + longitude: position.longitude, + ); + + // Send advertisement to mesh network + debugPrint( + '📡 [BackgroundLocation] Broadcasting self advertisement...', + ); + await _bleService!.sendSelfAdvert(floodMode: true); + debugPrint( + '✅ [BackgroundLocation] Location update sent successfully', + ); + } catch (e) { + debugPrint( + '❌ [BackgroundLocation] Failed to send location update: $e', + ); + } + } else { + debugPrint( + '⚠️ [BackgroundLocation] BLE disconnected, cannot send update', + ); + } + }); + + debugPrint( + '✅ [BackgroundLocation] Tracking started with ${distanceThreshold}m threshold', + ); + return true; + } catch (e) { + debugPrint('❌ [BackgroundLocation] Failed to start tracking: $e'); + return false; + } + } + + /// Stop location tracking + Future stopTracking() async { + 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 _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 _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 _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 updateDistanceThreshold(double distance) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_scopedKey(_prefKeyDistance), distance); + debugPrint( + '📏 [BackgroundLocation] Distance threshold updated to ${distance}m', + ); + + // Restart tracking if currently enabled + final isEnabled = prefs.getBool(_scopedKey(_prefKeyEnabled)) ?? false; + if (isEnabled && _bleService != null) { + await stopTracking(); + await startTracking(distanceThreshold: distance); + } + } + + LocationSettings _buildLocationSettings({int distanceFilter = 10}) { + if (Platform.isIOS) { + return AppleSettings( + accuracy: LocationAccuracy.best, + activityType: ActivityType.fitness, + allowBackgroundLocationUpdates: true, + distanceFilter: distanceFilter, + pauseLocationUpdatesAutomatically: false, + showBackgroundLocationIndicator: true, + ); + } + + return LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: distanceFilter, + ); + } +} + +@pragma('vm:entry-point') +Future 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(); + }); +} diff --git a/lib/services/bthome_met_history.dart b/lib/services/bthome_met_history.dart new file mode 100644 index 0000000..2758535 --- /dev/null +++ b/lib/services/bthome_met_history.dart @@ -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 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 = []; + 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.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 = [ + '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 bTHomeMetMeasurementsForContact(Contact? contact) { + final telemetry = contact?.telemetry; + if (telemetry == null || !_hasBTHomeMetCapability(contact)) { + return const []; + } + + final measurements = [ + 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.unmodifiable(measurements); +} + +bool supportsBTHomeMetHistory(Contact? contact) => + bTHomeMetMeasurementsForContact(contact).isNotEmpty; diff --git a/lib/services/build_info_service.dart b/lib/services/build_info_service.dart new file mode 100644 index 0000000..3bc526e --- /dev/null +++ b/lib/services/build_info_service.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb; +import 'package:flutter/services.dart'; + +/// Service for accessing build information from native platform code +/// Currently supports Android only - returns "unknown" for other platforms +class BuildInfoService { + static final BuildInfoService _instance = BuildInfoService._internal(); + factory BuildInfoService() => _instance; + BuildInfoService._internal(); + + static const MethodChannel _channel = MethodChannel('com.meshcore.sar/build_info'); + + String? _cachedCommitHash; + + /// Get the commit hash that was embedded during build time + /// Returns "unknown" if: + /// - Not running on Android + /// - Platform channel call fails + /// - Build was not configured with COMMIT_HASH + Future getCommitHash() async { + // Return cached value if available + if (_cachedCommitHash != null) { + return _cachedCommitHash!; + } + + // Only Android has the platform channel implementation + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"'); + _cachedCommitHash = 'unknown'; + return _cachedCommitHash!; + } + + try { + final String commitHash = await _channel.invokeMethod('getCommitHash'); + _cachedCommitHash = commitHash; + debugPrint('[BuildInfoService] Commit hash: $commitHash'); + return commitHash; + } on PlatformException catch (e) { + debugPrint('[BuildInfoService] Failed to get commit hash: ${e.message}'); + _cachedCommitHash = 'unknown'; + return _cachedCommitHash!; + } catch (e) { + debugPrint('[BuildInfoService] Unexpected error getting commit hash: $e'); + _cachedCommitHash = 'unknown'; + return _cachedCommitHash!; + } + } + + /// Clear cached commit hash (useful for testing) + void clearCache() { + _cachedCommitHash = null; + } +} diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart new file mode 100644 index 0000000..f1de915 --- /dev/null +++ b/lib/services/cayenne_lpp_parser.dart @@ -0,0 +1,942 @@ +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_client/meshcore_client.dart'; + +/// Cayenne LPP (Low Power Payload) data parser +/// Used for decoding telemetry sensor data from MeshCore devices +class CayenneLppParser { + static const int _selfTelemetryChannel = 1; + static const int _lppGenericSensor = 100; + static const int _lppCurrent = 117; + static const int _lppFrequency = 118; + static const int _lppPercentage = 120; + static const int _lppAltitude = 121; + static const int _lppConcentration = 125; + static const int _lppPower = 128; + static const int _lppSpeed = 129; + static const int _lppDistance = 130; + static const int _lppEnergy = 131; + static const int _lppDirection = 132; + static const int _lppUnixTime = 133; + static const int _lppColour = 135; + static const int _lppGust = 137; + static const int _lppDewPoint = 138; + static const int _lppRain = 139; + static const int _lppSwitch = 142; + static const int _lppBinaryBool = 143; + static const int _lppBinaryPowerSwitch = 144; + static const int _lppBinaryOpen = 145; + static const int _lppBinaryBatteryLow = 146; + static const int _lppBinaryCharging = 147; + static const int _lppBinaryCarbonMonoxide = 148; + static const int _lppBinaryCold = 149; + static const int _lppBinaryConnectivity = 150; + static const int _lppBinaryDoor = 151; + static const int _lppBinaryGarageDoor = 152; + static const int _lppBinaryGas = 153; + static const int _lppBinaryHeat = 154; + static const int _lppBinaryLight = 155; + static const int _lppBinaryLock = 156; + static const int _lppBinaryMoisture = 157; + static const int _lppBinaryMotion = 158; + static const int _lppBinaryMoving = 159; + static const int _lppBinaryOccupancy = 160; + static const int _lppBinaryPlug = 161; + static const int _lppBinaryPresence = 162; + static const int _lppBinaryProblem = 163; + static const int _lppBinaryRunning = 164; + static const int _lppBinarySafety = 165; + static const int _lppBinarySmoke = 166; + static const int _lppBinarySound = 167; + static const int _lppBinaryTamper = 168; + static const int _lppBinaryVibration = 169; + static const int _lppBinaryWindow = 170; + static const int _lppButtonEvent = 171; + static const int _lppDimmer = 172; + static const int _lppUv = 173; + static const int _lppLightLevel = 174; + static const int _lppPm25 = 175; + static const int _lppPm10 = 176; + static const int _lppCo2 = 177; + static const int _lppTvoc = 178; + static const int _lppRpm = 179; + static const int _lppConductivity = 180; + static const int _lppRotation = 181; + static const int _lppDuration = 182; + static const int _lppAcceleration = 183; + static const int _lppGyroRate = 184; + static const int _lppVolume = 185; + static const int _lppFlowRate = 186; + static const int _lppVolumeStorage = 187; + static const int _lppWater = 188; + static const int _lppGasVolume = 189; + static const int _lppMass = 190; + static const int _lppSignedSpeed = 191; + static const int _lppSignedPower = 192; + static const int _lppSignedCurrent = 193; + + /// Parse Cayenne LPP data into ContactTelemetry + static ContactTelemetry parse(Uint8List data) { + debugPrint(' [CayenneLPP] Parsing LPP data...'); + debugPrint(' Data length: ${data.length} bytes'); + debugPrint( + ' Data (hex): ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}', + ); + + final reader = BufferReader(data); + + LatLng? gpsLocation; + double? batteryPercentage; + double? batteryMilliVolts; + double? deferredBatteryMilliVolts; + double? deferredBatteryPercentage; + double? temperature; + double? humidity; + double? pressure; + final extraSensorData = {}; + bool sawNonSelfChannel = false; + + int fieldCount = 0; + while (reader.hasRemaining) { + if (fieldCount > 0 && + _isZeroPaddedTail(data, reader.remainingBytesCount)) { + debugPrint( + ' Detected zero-padded telemetry tail, stopping parse at position ' + '${data.length - reader.remainingBytesCount}', + ); + break; + } + + try { + fieldCount++; + debugPrint( + ' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}', + ); + + final channel = reader.readByte(); + debugPrint(' Channel: $channel'); + if (channel != _selfTelemetryChannel) { + sawNonSelfChannel = true; + } + + final type = reader.readByte(); + debugPrint( + ' Type: $type (0x${type.toRadixString(16).padLeft(2, '0')})', + ); + + switch (type) { + case MeshCoreConstants.lppDigitalInput: + final value = reader.readByte(); + debugPrint(' Digital Input: $value'); + extraSensorData['digital_input_$channel'] = value; + break; + + case MeshCoreConstants.lppDigitalOutput: + final value = reader.readByte(); + debugPrint(' Digital Output: $value'); + extraSensorData['digital_output_$channel'] = value; + break; + + case MeshCoreConstants.lppAnalogInput: + final rawValue = reader.readInt16BE(); + final value = rawValue / 100.0; + debugPrint(' Analog Input (raw): $rawValue'); + debugPrint(' Analog Input (volts): ${value}V'); + if (_isDedicatedBatteryChannel(channel)) { + batteryMilliVolts = value * 1000; + batteryPercentage = _calculateBatteryPercentage(value); + extraSensorData[_sourceChannelKey('battery')] = channel; + extraSensorData[_sourceChannelKey('voltage')] = channel; + 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; + } + break; + + case MeshCoreConstants.lppAnalogOutput: + final rawValue = reader.readInt16BE(); + final value = rawValue / 100.0; + debugPrint(' Analog Output (raw): $rawValue'); + debugPrint(' Analog Output (volts): ${value}V'); + extraSensorData['analog_output_$channel'] = value; + break; + + case MeshCoreConstants.lppIlluminanceSensor: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Illuminance: $value lux'); + extraSensorData['illuminance_$channel'] = value; + break; + + case MeshCoreConstants.lppPresenceSensor: + final value = reader.readByte(); + debugPrint(' Presence: $value'); + extraSensorData['presence_$channel'] = value; + break; + + case MeshCoreConstants.lppTemperatureSensor: + final rawValue = reader.readInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Temperature (raw): $rawValue'); + debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C'); + if (channel == _selfTelemetryChannel) { + temperature = value; + extraSensorData[_sourceChannelKey('temperature')] = channel; + } else { + extraSensorData['temperature_$channel'] = value; + if (temperature == null) { + temperature = value; + extraSensorData[_sourceChannelKey('temperature')] = channel; + } + } + break; + + case MeshCoreConstants.lppHumiditySensor: + final rawValue = reader.readByte(); + final value = rawValue / 2.0; + debugPrint(' Humidity (raw): $rawValue'); + debugPrint(' Humidity: ${value.toStringAsFixed(1)}%'); + if (channel == _selfTelemetryChannel) { + humidity = value; + extraSensorData[_sourceChannelKey('humidity')] = channel; + } else { + extraSensorData['humidity_$channel'] = value; + if (humidity == null) { + humidity = value; + extraSensorData[_sourceChannelKey('humidity')] = channel; + } + } + break; + + case MeshCoreConstants.lppAccelerometer: + final x = reader.readInt16BE() / 1000.0; + final y = reader.readInt16BE() / 1000.0; + final z = reader.readInt16BE() / 1000.0; + debugPrint(' Accelerometer: x=$x, y=$y, z=$z'); + extraSensorData['accelerometer_$channel'] = { + 'x': x, + 'y': y, + 'z': z, + }; + break; + + case MeshCoreConstants.lppBarometer: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Barometer (raw): $rawValue'); + debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa'); + if (channel == _selfTelemetryChannel) { + pressure = value; + extraSensorData[_sourceChannelKey('pressure')] = channel; + } else { + extraSensorData['pressure_$channel'] = value; + if (pressure == null) { + pressure = value; + extraSensorData[_sourceChannelKey('pressure')] = channel; + } + } + break; + + case MeshCoreConstants.lppVoltageSensor: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 100.0; + debugPrint(' Voltage (raw): $rawValue'); + debugPrint(' Voltage: ${value}V'); + if (_isDedicatedBatteryChannel(channel)) { + batteryMilliVolts = value * 1000; + batteryPercentage = _calculateBatteryPercentage(value); + extraSensorData[_sourceChannelKey('battery')] = channel; + extraSensorData[_sourceChannelKey('voltage')] = channel; + debugPrint( + ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', + ); + } else if (_isDeferredBatteryChannel(channel)) { + deferredBatteryMilliVolts = value * 1000; + deferredBatteryPercentage = _calculateBatteryPercentage(value); + extraSensorData['voltage_$channel'] = value; + } else { + extraSensorData['voltage_$channel'] = value; + } + break; + + case MeshCoreConstants.lppGyrometer: + final x = reader.readInt16BE() / 100.0; + final y = reader.readInt16BE() / 100.0; + final z = reader.readInt16BE() / 100.0; + debugPrint(' Gyrometer: x=$x, y=$y, z=$z'); + extraSensorData['gyrometer_$channel'] = {'x': x, 'y': y, 'z': z}; + break; + + case MeshCoreConstants.lppGps: + // Standard Cayenne LPP GPS format (type 0x88): + // - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000 + // - Longitude: 3 bytes, signed 24-bit, big-endian, × 10000 + // - Altitude: 3 bytes, signed 24-bit, big-endian, × 100 + // Total: 9 bytes (not the 12 bytes used in MeshCore advertisements!) + + // Read 3-byte signed big-endian integers + final latBytes = reader.readBytes(3); + int rawLat = (latBytes[0] << 16) | (latBytes[1] << 8) | latBytes[2]; + // Sign extend from 24-bit to 32-bit + if (rawLat > 0x7FFFFF) rawLat = rawLat - 0x1000000; + + final lonBytes = reader.readBytes(3); + int rawLon = (lonBytes[0] << 16) | (lonBytes[1] << 8) | lonBytes[2]; + if (rawLon > 0x7FFFFF) rawLon = rawLon - 0x1000000; + + final altBytes = reader.readBytes(3); + int rawAlt = (altBytes[0] << 16) | (altBytes[1] << 8) | altBytes[2]; + if (rawAlt > 0x7FFFFF) rawAlt = rawAlt - 0x1000000; + + // Decode: divide by scaling factors + final lat = rawLat / 10000.0; + final lon = rawLon / 10000.0; + final alt = rawAlt / 100.0; + + debugPrint( + ' GPS Location (raw 24-bit BE): lat=$rawLat (0x${rawLat.toRadixString(16).padLeft(6, '0')}), lon=$rawLon (0x${rawLon.toRadixString(16).padLeft(6, '0')}), alt=$rawAlt (0x${rawAlt.toRadixString(16).padLeft(6, '0')})', + ); + debugPrint( + ' GPS Location (decoded): ${lat.toStringAsFixed(6)}°, ${lon.toStringAsFixed(6)}°, altitude=${alt.toStringAsFixed(2)}m', + ); + + // Validate coordinates are in valid range + if (lat < -90.0 || lat > 90.0) { + debugPrint(' ⚠️ WARNING: Latitude out of range: $lat°'); + } + if (lon < -180.0 || lon > 180.0) { + debugPrint(' ⚠️ WARNING: Longitude out of range: $lon°'); + } + + gpsLocation = LatLng(lat, lon); + extraSensorData[_sourceChannelKey('gps')] = channel; + extraSensorData['altitude_$channel'] = alt; + break; + + case _lppGenericSensor: + final value = _readUInt32BE(reader).toDouble(); + debugPrint(' Generic Sensor: $value'); + extraSensorData['generic_sensor_$channel'] = value; + break; + + case _lppCurrent: + final rawValue = reader.readInt16BE(); + final value = rawValue / 1000.0; + debugPrint(' Current (raw): $rawValue'); + debugPrint(' Current: ${value}A'); + extraSensorData['current_$channel'] = value; + break; + + case _lppFrequency: + final value = _readUInt32BE(reader).toDouble(); + debugPrint(' Frequency: ${value}Hz'); + extraSensorData['frequency_$channel'] = value; + break; + + case _lppPercentage: + final value = reader.readByte().toDouble(); + debugPrint(' Percentage: $value%'); + if (_isDedicatedBatteryChannel(channel) || _isDeferredBatteryChannel(channel)) { + batteryPercentage = value; + extraSensorData[_sourceChannelKey('battery')] = channel; + } else { + extraSensorData['percentage_$channel'] = value; + } + break; + + case _lppAltitude: + final rawValue = reader.readInt16BE(); + final value = rawValue.toDouble(); + debugPrint(' Altitude: ${value}m'); + extraSensorData['altitude_$channel'] = value; + break; + + case _lppConcentration: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Concentration: ${value}ppm'); + extraSensorData['concentration_$channel'] = value; + break; + + case _lppPower: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Power: ${value}W'); + extraSensorData['power_$channel'] = value; + break; + + case _lppSpeed: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 100.0; + debugPrint(' Speed: ${value}m/s'); + extraSensorData['speed_$channel'] = value; + break; + + case _lppDistance: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Distance: ${value}m'); + extraSensorData['distance_$channel'] = value; + break; + + case _lppEnergy: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Energy: ${value}kWh'); + extraSensorData['energy_$channel'] = value; + break; + + case _lppDirection: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Direction: $value°'); + extraSensorData['direction_$channel'] = value; + break; + + case _lppUnixTime: + final value = _readUInt32BE(reader); + debugPrint(' Unix time: $value'); + extraSensorData['unixtime_$channel'] = value; + break; + + case _lppColour: + final red = reader.readByte(); + final green = reader.readByte(); + final blue = reader.readByte(); + debugPrint(' Colour: r=$red, g=$green, b=$blue'); + extraSensorData['colour_$channel'] = { + 'r': red, + 'g': green, + 'b': blue, + }; + break; + + case _lppGust: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 100.0; + debugPrint(' Gust: ${value}m/s'); + extraSensorData['gust_$channel'] = value; + break; + + case _lppDewPoint: + final rawValue = reader.readInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C'); + extraSensorData['dew_$channel'] = value; + break; + + case _lppRain: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Rain: ${value}mm'); + extraSensorData['rain_$channel'] = value; + break; + + case _lppBinaryBool: + case _lppBinaryPowerSwitch: + case _lppBinaryOpen: + case _lppBinaryBatteryLow: + case _lppBinaryCharging: + case _lppBinaryCarbonMonoxide: + case _lppBinaryCold: + case _lppBinaryConnectivity: + case _lppBinaryDoor: + case _lppBinaryGarageDoor: + case _lppBinaryGas: + case _lppBinaryHeat: + case _lppBinaryLight: + case _lppBinaryLock: + case _lppBinaryMoisture: + case _lppBinaryMotion: + case _lppBinaryMoving: + case _lppBinaryOccupancy: + case _lppBinaryPlug: + case _lppBinaryPresence: + case _lppBinaryProblem: + case _lppBinaryRunning: + case _lppBinarySafety: + case _lppBinarySmoke: + case _lppBinarySound: + case _lppBinaryTamper: + case _lppBinaryVibration: + case _lppBinaryWindow: + final value = reader.readByte(); + debugPrint(' Binary state: $value'); + extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] = + value; + break; + + case _lppButtonEvent: + final value = reader.readByte(); + debugPrint(' Button event: $value'); + extraSensorData['button_event_$channel'] = value; + break; + + case _lppDimmer: + final value = _readInt8(reader); + debugPrint(' Dimmer: $value'); + extraSensorData['dimmer_$channel'] = value; + break; + + case _lppUv: + final value = reader.readByte() / 10.0; + debugPrint(' UV index: $value'); + extraSensorData['uv_$channel'] = value; + break; + + case _lppLightLevel: + final value = reader.readByte(); + debugPrint(' Light level: $value'); + extraSensorData['light_level_$channel'] = value; + break; + + case _lppPm25: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' PM2.5: $value'); + extraSensorData['pm25_$channel'] = value; + break; + + case _lppPm10: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' PM10: $value'); + extraSensorData['pm10_$channel'] = value; + break; + + case _lppCo2: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' CO2: $value'); + extraSensorData['co2_$channel'] = value; + break; + + case _lppTvoc: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' TVOC: $value'); + extraSensorData['tvoc_$channel'] = value; + break; + + case _lppRpm: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' RPM: $value'); + extraSensorData['rpm_$channel'] = value; + break; + + case _lppConductivity: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Conductivity: $value'); + extraSensorData['conductivity_$channel'] = value; + break; + + case _lppRotation: + final rawValue = reader.readInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Rotation: $value'); + extraSensorData['rotation_$channel'] = value; + break; + + case _lppDuration: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Duration: $value s'); + extraSensorData['duration_$channel'] = value; + break; + + case _lppAcceleration: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000000.0; + debugPrint(' Acceleration: $value'); + extraSensorData['acceleration_$channel'] = value; + break; + + case _lppGyroRate: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Gyro rate: $value'); + extraSensorData['gyro_rate_$channel'] = value; + break; + + case _lppVolume: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Volume: $value'); + extraSensorData['volume_$channel'] = value; + break; + + case _lppFlowRate: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Flow rate: $value'); + extraSensorData['flow_rate_$channel'] = value; + break; + + case _lppVolumeStorage: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Storage volume: $value'); + extraSensorData['volume_storage_$channel'] = value; + break; + + case _lppWater: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Water: $value'); + extraSensorData['water_$channel'] = value; + break; + + case _lppGasVolume: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Gas volume: $value'); + extraSensorData['gas_volume_$channel'] = value; + break; + + case _lppMass: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Mass: $value'); + extraSensorData['mass_$channel'] = value; + break; + + case _lppSignedSpeed: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000000.0; + debugPrint(' Signed speed: $value'); + extraSensorData['signed_speed_$channel'] = value; + break; + + case _lppSignedPower: + final rawValue = _readInt32BE(reader); + final value = rawValue / 100.0; + debugPrint(' Signed power: $value'); + extraSensorData['signed_power_$channel'] = value; + break; + + case _lppSignedCurrent: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Signed current: $value'); + extraSensorData['signed_current_$channel'] = value; + break; + + case _lppSwitch: + final value = reader.readByte(); + debugPrint(' Switch: $value'); + extraSensorData['switch_$channel'] = value; + break; + + default: + final size = _payloadSizeForType(type); + if (size == null || reader.remainingBytesCount < size) { + debugPrint( + ' ⚠️ Unknown type $type with unsupported size, stopping parse', + ); + reader.skip(reader.remainingBytesCount); + break; + } + debugPrint(' ⚠️ Unknown type $type, skipping $size bytes'); + reader.skip(size); + break; + } + } catch (e) { + debugPrint(' ❌ Parsing error: $e'); + // If we encounter a parsing error, break and return what we have + break; + } + } + + 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( + ' GPS: ${gpsLocation != null ? '${gpsLocation.latitude}°, ${gpsLocation.longitude}°' : 'none'}', + ); + debugPrint( + ' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}', + ); + debugPrint( + ' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}', + ); + + // IMPORTANT: Cayenne LPP format does NOT include a timestamp field. + // We use DateTime.now() as the timestamp, which represents when the data + // was RECEIVED/PARSED by the app, NOT when it was collected by the device. + // + // This means: + // - If the device sends cached/old telemetry data, the timestamp will still + // show as "recent" (a few seconds ago) because it was just received + // - The actual age of the telemetry data cannot be determined from the LPP format + // - Devices may cache telemetry for hours and send it later when requested + final parseTimestamp = DateTime.now(); + debugPrint( + ' Timestamp: $parseTimestamp (parse time, NOT device collection time)', + ); + + return ContactTelemetry( + gpsLocation: gpsLocation, + batteryPercentage: batteryPercentage, + batteryMilliVolts: batteryMilliVolts, + temperature: temperature, + humidity: humidity, + pressure: pressure, + timestamp: parseTimestamp, + extraSensorData: extraSensorData.isNotEmpty ? extraSensorData : null, + ); + } + + /// Calculate battery percentage from voltage (V) + static double _calculateBatteryPercentage(double voltage) { + // Standard lithium battery curve: 3.0V = 0%, 4.2V = 100% + if (voltage <= 3.0) return 0.0; + if (voltage >= 4.2) return 100.0; + return ((voltage - 3.0) / 1.2) * 100.0; + } + + static bool _isDedicatedBatteryChannel(int channel) => channel == 0; + + static bool _isDeferredBatteryChannel(int channel) => + channel == _selfTelemetryChannel; + + static int _readUInt32BE(BufferReader reader) { + final bytes = reader.readBytes(4); + return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]; + } + + static int _readInt32BE(BufferReader reader) { + final value = _readUInt32BE(reader); + if ((value & 0x80000000) != 0) { + return value - 0x100000000; + } + return value; + } + + static int _readInt8(BufferReader reader) { + final value = reader.readByte(); + if ((value & 0x80) != 0) { + return value - 0x100; + } + return value; + } + + static String _sourceChannelKey(String fieldKey) => + '__source_channel:$fieldKey'; + + static String _binaryMetricKeyForType(int type) { + switch (type) { + case _lppBinaryBool: + return 'binary_bool'; + case _lppBinaryPowerSwitch: + return 'binary_power_switch'; + case _lppBinaryOpen: + return 'binary_open'; + case _lppBinaryBatteryLow: + return 'binary_battery_low'; + case _lppBinaryCharging: + return 'binary_charging'; + case _lppBinaryCarbonMonoxide: + return 'binary_carbon_monoxide'; + case _lppBinaryCold: + return 'binary_cold'; + case _lppBinaryConnectivity: + return 'binary_connectivity'; + case _lppBinaryDoor: + return 'binary_door'; + case _lppBinaryGarageDoor: + return 'binary_garage_door'; + case _lppBinaryGas: + return 'binary_gas'; + case _lppBinaryHeat: + return 'binary_heat'; + case _lppBinaryLight: + return 'binary_light'; + case _lppBinaryLock: + return 'binary_lock'; + case _lppBinaryMoisture: + return 'binary_moisture'; + case _lppBinaryMotion: + return 'binary_motion'; + case _lppBinaryMoving: + return 'binary_moving'; + case _lppBinaryOccupancy: + return 'binary_occupancy'; + case _lppBinaryPlug: + return 'binary_plug'; + case _lppBinaryPresence: + return 'binary_presence'; + case _lppBinaryProblem: + return 'binary_problem'; + case _lppBinaryRunning: + return 'binary_running'; + case _lppBinarySafety: + return 'binary_safety'; + case _lppBinarySmoke: + return 'binary_smoke'; + case _lppBinarySound: + return 'binary_sound'; + case _lppBinaryTamper: + return 'binary_tamper'; + case _lppBinaryVibration: + return 'binary_vibration'; + case _lppBinaryWindow: + return 'binary_window'; + } + return 'binary_state'; + } + + static int? _payloadSizeForType(int type) { + if (type >= _lppBinaryBool && type <= _lppBinaryWindow) { + return 1; + } + switch (type) { + case MeshCoreConstants.lppDigitalInput: + case MeshCoreConstants.lppDigitalOutput: + case MeshCoreConstants.lppPresenceSensor: + case MeshCoreConstants.lppHumiditySensor: + case _lppPercentage: + case _lppSwitch: + case _lppButtonEvent: + case _lppDimmer: + case _lppUv: + case _lppLightLevel: + return 1; + case MeshCoreConstants.lppAnalogInput: + case MeshCoreConstants.lppAnalogOutput: + case MeshCoreConstants.lppIlluminanceSensor: + case MeshCoreConstants.lppTemperatureSensor: + case MeshCoreConstants.lppBarometer: + case MeshCoreConstants.lppVoltageSensor: + case _lppCurrent: + case _lppAltitude: + case _lppConcentration: + case _lppPower: + case _lppSpeed: + case _lppDirection: + case _lppGust: + case _lppDewPoint: + case _lppRain: + case _lppPm25: + case _lppPm10: + case _lppCo2: + case _lppTvoc: + case _lppRpm: + case _lppConductivity: + case _lppRotation: + return 2; + case MeshCoreConstants.lppAccelerometer: + case MeshCoreConstants.lppGyrometer: + return 6; + case MeshCoreConstants.lppGps: + return 9; + case _lppGenericSensor: + case _lppFrequency: + case _lppDistance: + case _lppEnergy: + case _lppUnixTime: + case _lppDuration: + case _lppAcceleration: + case _lppGyroRate: + case _lppVolume: + case _lppFlowRate: + case _lppVolumeStorage: + case _lppWater: + case _lppGasVolume: + case _lppMass: + case _lppSignedSpeed: + case _lppSignedPower: + case _lppSignedCurrent: + return 4; + case _lppColour: + return 3; + } + return null; + } + + static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) { + final start = data.length - remainingBytes; + for (int i = start; i < data.length; i++) { + if (data[i] != 0) return false; + } + return remainingBytes > 0; + } + + /// Create Cayenne LPP data for GPS location + /// Standard Cayenne LPP GPS format (type 0x88): + /// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000 + /// - Longitude: 3 bytes, signed 24-bit, big-endian, × 10000 + /// - Altitude: 3 bytes, signed 24-bit, big-endian, × 100 + static Uint8List createGpsData({ + required double latitude, + required double longitude, + double altitude = 0.0, + int channel = 0, + }) { + final buffer = []; + + buffer.add(channel); + buffer.add(MeshCoreConstants.lppGps); + + // Latitude (signed 24-bit BE, 3 bytes, 0.0001° precision) + int lat = (latitude * 10000).round(); + // Handle negative values (two's complement for 24-bit) + if (lat < 0) lat = lat + 0x1000000; + buffer.add((lat >> 16) & 0xFF); // Byte 0 (MSB) + buffer.add((lat >> 8) & 0xFF); // Byte 1 + buffer.add(lat & 0xFF); // Byte 2 (LSB) + + // Longitude (signed 24-bit BE, 3 bytes, 0.0001° precision) + int lon = (longitude * 10000).round(); + if (lon < 0) lon = lon + 0x1000000; + buffer.add((lon >> 16) & 0xFF); // Byte 0 (MSB) + buffer.add((lon >> 8) & 0xFF); // Byte 1 + buffer.add(lon & 0xFF); // Byte 2 (LSB) + + // Altitude (signed 24-bit BE, 3 bytes, 0.01m precision) + int alt = (altitude * 100).round(); + if (alt < 0) alt = alt + 0x1000000; + buffer.add((alt >> 16) & 0xFF); // Byte 0 (MSB) + buffer.add((alt >> 8) & 0xFF); // Byte 1 + buffer.add(alt & 0xFF); // Byte 2 (LSB) + + return Uint8List.fromList(buffer); + } + + /// Create Cayenne LPP data for temperature + static Uint8List createTemperatureData(double celsius, {int channel = 0}) { + final buffer = []; + buffer.add(channel); + buffer.add(MeshCoreConstants.lppTemperatureSensor); + + final temp = (celsius * 10).round(); + buffer.add((temp >> 8) & 0xFF); + buffer.add(temp & 0xFF); + + return Uint8List.fromList(buffer); + } + + /// Create Cayenne LPP data for battery voltage + static Uint8List createBatteryData(double voltage, {int channel = 0}) { + final buffer = []; + buffer.add(channel); + buffer.add(MeshCoreConstants.lppAnalogInput); + + final volts = (voltage * 100).round(); + buffer.add((volts >> 8) & 0xFF); + buffer.add(volts & 0xFF); + + return Uint8List.fromList(buffer); + } +} diff --git a/lib/services/compass_support.dart b/lib/services/compass_support.dart new file mode 100644 index 0000000..b3dcf61 --- /dev/null +++ b/lib/services/compass_support.dart @@ -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); +} diff --git a/lib/services/contact_route_resolver.dart b/lib/services/contact_route_resolver.dart new file mode 100644 index 0000000..7f49407 --- /dev/null +++ b/lib/services/contact_route_resolver.dart @@ -0,0 +1,336 @@ +import 'dart:math' as math; + +import 'package:latlong2/latlong.dart'; + +import '../models/contact.dart'; + +class ResolvedContactRoutePlan { + final List tokens; + final List selectedContacts; + final String summary; + + const ResolvedContactRoutePlan({ + required this.tokens, + required this.selectedContacts, + required this.summary, + }); + + String get canonicalText => tokens.join(','); +} + +class ContactRouteResolver { + static const Distance _distance = Distance(); + + const ContactRouteResolver._(); + + static ResolvedContactRoutePlan? resolveAutomaticRoute({ + required LatLng senderLocation, + required Contact recipient, + required List availableContacts, + required int hashSize, + }) { + final recipientLocation = recipient.displayLocation; + if (recipientLocation == null) return null; + + final repeaters = availableContacts + .where( + (contact) => + contact.isRepeater && + contact.displayLocation != null && + contact.publicKeyHex != recipient.publicKeyHex, + ) + .toList(); + if (repeaters.isEmpty) return null; + + final recipientLatLng = LatLng( + recipientLocation.latitude, + recipientLocation.longitude, + ); + + final routedCandidates = + repeaters + .where( + (contact) => + contact.routeHasPath && contact.routeHashSize == hashSize, + ) + .toList() + ..sort( + (a, b) => + _scoreKnownRouteRepeater( + senderLocation: senderLocation, + recipientLocation: recipientLatLng, + repeater: a, + availableContacts: repeaters, + hashSize: hashSize, + ).compareTo( + _scoreKnownRouteRepeater( + senderLocation: senderLocation, + recipientLocation: recipientLatLng, + repeater: b, + availableContacts: repeaters, + hashSize: hashSize, + ), + ), + ); + + if (routedCandidates.isNotEmpty) { + final anchor = routedCandidates.first; + final tokens = [ + ...anchor.routeCanonicalText + .split(',') + .where((token) => token.isNotEmpty) + .map((token) => token.toUpperCase()), + _tokenFor(anchor, hashSize), + ]; + final selectedContacts = _matchContactsForTokens( + tokens, + availableContacts: repeaters, + ); + return ResolvedContactRoutePlan( + tokens: _dedupeTokens(tokens), + selectedContacts: selectedContacts, + summary: 'Resolved via known repeater route', + ); + } + + final corridorRepeaters = List.from(repeaters) + ..sort((a, b) { + final progressA = _progressAlongSegment( + point: LatLng( + a.displayLocation!.latitude, + a.displayLocation!.longitude, + ), + start: senderLocation, + end: recipientLatLng, + ); + final progressB = _progressAlongSegment( + point: LatLng( + b.displayLocation!.latitude, + b.displayLocation!.longitude, + ), + start: senderLocation, + end: recipientLatLng, + ); + final progressCompare = progressA.compareTo(progressB); + if (progressCompare != 0) return progressCompare; + return _scoreRepeater( + senderLocation: senderLocation, + recipientLocation: recipientLatLng, + repeater: a, + ).compareTo( + _scoreRepeater( + senderLocation: senderLocation, + recipientLocation: recipientLatLng, + repeater: b, + ), + ); + }); + + final selected = []; + var currentPoint = senderLocation; + var currentDistanceToRecipient = _distance.as( + LengthUnit.Meter, + senderLocation, + recipientLatLng, + ); + final maxCorridorDistance = math.max( + 1500.0, + currentDistanceToRecipient * 0.22, + ); + + for (final repeater in corridorRepeaters) { + if (selected.length >= 4) break; + final repeaterPoint = LatLng( + repeater.displayLocation!.latitude, + repeater.displayLocation!.longitude, + ); + final distanceToSegment = _distanceToSegmentMeters( + repeaterPoint, + senderLocation, + recipientLatLng, + ); + if (distanceToSegment > maxCorridorDistance) { + continue; + } + + final nextDistanceToRecipient = _distance.as( + LengthUnit.Meter, + repeaterPoint, + recipientLatLng, + ); + if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) { + continue; + } + + final distanceFromCurrent = _distance.as( + LengthUnit.Meter, + currentPoint, + repeaterPoint, + ); + if (distanceFromCurrent < 100) { + continue; + } + + selected.add(repeater); + currentPoint = repeaterPoint; + currentDistanceToRecipient = nextDistanceToRecipient; + + if (currentDistanceToRecipient < 2500) { + break; + } + } + + if (selected.isEmpty) { + return null; + } + + return ResolvedContactRoutePlan( + tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(), + selectedContacts: selected, + summary: 'Resolved from repeater locations', + ); + } + + static List _matchContactsForTokens( + List tokens, { + required List availableContacts, + }) { + final matches = []; + final seen = {}; + for (final token in tokens) { + final match = availableContacts + .where( + (contact) => contact.publicKeyHex.toUpperCase().startsWith(token), + ) + .firstOrNull; + if (match != null && seen.add(match.publicKeyHex)) { + matches.add(match); + } + } + return matches; + } + + static List _dedupeTokens(List tokens) { + final result = []; + for (final token in tokens) { + if (result.isEmpty || result.last != token) { + result.add(token); + } + } + return result; + } + + static String _tokenFor(Contact contact, int hashSize) { + final hex = contact.publicKeyHex.toUpperCase(); + final length = hashSize * 2; + return hex.length < length ? hex : hex.substring(0, length); + } + + static double _scoreRepeater({ + required LatLng senderLocation, + required LatLng recipientLocation, + required Contact repeater, + }) { + final point = LatLng( + repeater.displayLocation!.latitude, + repeater.displayLocation!.longitude, + ); + final toRecipient = _distance.as( + LengthUnit.Meter, + point, + recipientLocation, + ); + final corridor = _distanceToSegmentMeters( + point, + senderLocation, + recipientLocation, + ); + return (toRecipient * 0.75) + (corridor * 0.25); + } + + static double _scoreKnownRouteRepeater({ + required LatLng senderLocation, + required LatLng recipientLocation, + required Contact repeater, + required List availableContacts, + required int hashSize, + }) { + final baseScore = _scoreRepeater( + senderLocation: senderLocation, + recipientLocation: recipientLocation, + repeater: repeater, + ); + final repeaterPoint = LatLng( + repeater.displayLocation!.latitude, + repeater.displayLocation!.longitude, + ); + + final chainContacts = repeater.routeCanonicalText + .split(',') + .where((token) => token.isNotEmpty) + .map( + (token) => availableContacts + .where( + (contact) => + contact.displayLocation != null && + contact.publicKeyHex.toUpperCase().startsWith( + token.toUpperCase(), + ), + ) + .firstOrNull, + ) + .whereType() + .toList(); + + final chainEnd = chainContacts.isNotEmpty + ? LatLng( + chainContacts.last.displayLocation!.latitude, + chainContacts.last.displayLocation!.longitude, + ) + : senderLocation; + final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint); + final senderGap = _distance.as( + LengthUnit.Meter, + senderLocation, + repeaterPoint, + ); + + return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10); + } + + static double _progressAlongSegment({ + required LatLng point, + required LatLng start, + required LatLng end, + }) { + final dx = end.longitude - start.longitude; + final dy = end.latitude - start.latitude; + final lengthSquared = (dx * dx) + (dy * dy); + if (lengthSquared == 0) return 0; + return (((point.longitude - start.longitude) * dx) + + ((point.latitude - start.latitude) * dy)) / + lengthSquared; + } + + static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) { + final ax = a.longitude; + final ay = a.latitude; + final bx = b.longitude; + final by = b.latitude; + final px = p.longitude; + final py = p.latitude; + + final abx = bx - ax; + final aby = by - ay; + final apx = px - ax; + final apy = py - ay; + final ab2 = abx * abx + aby * aby; + if (ab2 == 0) { + return _distance.as(LengthUnit.Meter, a, p); + } + var t = (apx * abx + apy * aby) / ab2; + t = t.clamp(0.0, 1.0); + final closest = LatLng(ay + aby * t, ax + abx * t); + return _distance.as(LengthUnit.Meter, closest, p); + } +} diff --git a/lib/services/contact_storage_service.dart b/lib/services/contact_storage_service.dart new file mode 100644 index 0000000..b71fdd3 --- /dev/null +++ b/lib/services/contact_storage_service.dart @@ -0,0 +1,424 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/contact.dart'; +import '../models/contact_group.dart'; +import 'profiles_feature_service.dart'; +import '../utils/key_comparison.dart'; +import 'package:latlong2/latlong.dart'; + +/// Service for persisting contacts to local storage +class ContactStorageService { + static const String _contactsKey = 'stored_contacts'; + static const String _contactGroupsKey = 'stored_contact_groups'; + static const String _pendingAdvertsKey = 'stored_pending_adverts'; + static const int _maxStoredContacts = 500; // Store up to 500 contacts + static const int _maxStoredPendingAdverts = 500; + + String _key(String baseKey, {String? namespace}) { + return ProfileStorageScope.scopedKey(baseKey, namespace: namespace); + } + + /// Save contacts to persistent storage + Future saveContacts(List contacts, {String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + + // Convert contacts to JSON + final jsonList = contacts + .map((contact) => _contactToJson(contact)) + .toList(); + + // Limit to max stored contacts (keep most recent) + final limitedList = jsonList.length > _maxStoredContacts + ? jsonList.sublist(jsonList.length - _maxStoredContacts) + : jsonList; + + final jsonString = jsonEncode(limitedList); + await prefs.setString( + _key(_contactsKey, namespace: namespace), + jsonString, + ); + + debugPrint( + '✅ [ContactStorage] Saved ${limitedList.length} contacts to storage', + ); + } catch (e) { + debugPrint('❌ [ContactStorage] Error saving contacts: $e'); + } + } + + /// Load contacts from persistent storage + /// [excludePublicKey] - optional public key to exclude (e.g., device's own key) + Future> loadContacts({ + Uint8List? excludePublicKey, + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_contactsKey, namespace: namespace), + ); + + if (jsonString == null || jsonString.isEmpty) { + debugPrint('ℹ️ [ContactStorage] No stored contacts found'); + return []; + } + + final jsonList = jsonDecode(jsonString) as List; + final contacts = jsonList + .map((json) => _contactFromJson(json as Map)) + .where((contact) => contact != null) + .cast() + .toList(); + + // Filter out contacts with the excluded public key + final filteredContacts = excludePublicKey != null + ? contacts.where((contact) { + final matches = contact.publicKey.matches(excludePublicKey); + if (matches) { + debugPrint( + 'ℹ️ [ContactStorage] Excluding contact with matching public key: ${contact.advName}', + ); + } + return !matches; + }).toList() + : contacts; + + debugPrint( + '✅ [ContactStorage] Loaded ${filteredContacts.length} contacts from storage' + '${excludePublicKey != null ? ' (${contacts.length - filteredContacts.length} excluded)' : ''}', + ); + return filteredContacts; + } catch (e) { + debugPrint('❌ [ContactStorage] Error loading contacts: $e'); + return []; + } + } + + /// Clear all stored contacts + Future clearContacts({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key(_contactsKey, namespace: namespace)); + debugPrint('✅ [ContactStorage] Cleared all stored contacts'); + } catch (e) { + debugPrint('❌ [ContactStorage] Error clearing contacts: $e'); + } + } + + Future saveContactGroups( + List groups, { + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = jsonEncode( + groups.map((group) => _contactGroupToJson(group)).toList(), + ); + await prefs.setString( + _key(_contactGroupsKey, namespace: namespace), + jsonString, + ); + debugPrint( + '✅ [ContactStorage] Saved ${groups.length} contact groups to storage', + ); + } catch (e) { + debugPrint('❌ [ContactStorage] Error saving contact groups: $e'); + } + } + + Future> loadContactGroups({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_contactGroupsKey, namespace: namespace), + ); + if (jsonString == null || jsonString.isEmpty) { + return []; + } + + final jsonList = jsonDecode(jsonString) as List; + return jsonList + .map((json) => _contactGroupFromJson(json as Map)) + .whereType() + .toList(); + } catch (e) { + debugPrint('❌ [ContactStorage] Error loading contact groups: $e'); + return []; + } + } + + Future clearContactGroups({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key(_contactGroupsKey, namespace: namespace)); + debugPrint('✅ [ContactStorage] Cleared all contact groups'); + } catch (e) { + debugPrint('❌ [ContactStorage] Error clearing contact groups: $e'); + } + } + + Future savePendingAdverts( + List> adverts, { + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final limitedList = adverts.length > _maxStoredPendingAdverts + ? adverts.sublist(adverts.length - _maxStoredPendingAdverts) + : adverts; + await prefs.setString( + _key(_pendingAdvertsKey, namespace: namespace), + jsonEncode(limitedList), + ); + debugPrint( + '✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage', + ); + } catch (e) { + debugPrint('❌ [ContactStorage] Error saving pending adverts: $e'); + } + } + + Future>> loadPendingAdverts({ + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_pendingAdvertsKey, namespace: namespace), + ); + if (jsonString == null || jsonString.isEmpty) { + return const []; + } + + final jsonList = jsonDecode(jsonString) as List; + return jsonList.whereType>().toList(); + } catch (e) { + debugPrint('❌ [ContactStorage] Error loading pending adverts: $e'); + return const []; + } + } + + Future clearPendingAdverts({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key(_pendingAdvertsKey, namespace: namespace)); + debugPrint('✅ [ContactStorage] Cleared all stored pending adverts'); + } catch (e) { + debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e'); + } + } + + // ── Favorites ─────────────────────────────────────────────────────────────── + + static const String _favoritesKey = 'contact_favorites'; + + Future saveFavorites( + Set publicKeyHexes, { + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _key(_favoritesKey, namespace: namespace), + publicKeyHexes.toList(), + ); + } catch (e) { + debugPrint('❌ [ContactStorage] Error saving favorites: $e'); + } + } + + Future> loadFavorites({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList( + _key(_favoritesKey, namespace: namespace), + ); + return list?.toSet() ?? {}; + } catch (e) { + debugPrint('❌ [ContactStorage] Error loading favorites: $e'); + return {}; + } + } + + /// Get storage statistics + Future> getStorageStats({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_contactsKey, namespace: namespace), + ); + + if (jsonString == null || jsonString.isEmpty) { + return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; + } + + final sizeBytes = jsonString.length; + final jsonList = jsonDecode(jsonString) as List; + + return { + 'contactCount': jsonList.length, + 'storageSizeBytes': sizeBytes, + 'storageSizeKB': (sizeBytes / 1024).toStringAsFixed(2), + }; + } catch (e) { + debugPrint('❌ [ContactStorage] Error getting storage stats: $e'); + return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; + } + } + + /// Convert Contact to JSON + Map _contactToJson(Contact contact) { + return { + 'publicKey': base64Encode(contact.publicKey), + 'type': contact.type.value, + 'flags': contact.flags, + 'outPathLen': contact.outPathLen, + 'outPath': base64Encode(contact.outPath), + 'advName': contact.advName, + 'nameOverride': contact.nameOverride, + 'lastAdvert': contact.lastAdvert, + 'advLat': contact.advLat, + 'advLon': contact.advLon, + 'lastMod': contact.lastMod, + 'telemetry': contact.telemetry != null + ? _telemetryToJson(contact.telemetry!) + : null, + 'advertHistory': contact.advertHistory + .map( + (point) => { + 'lat': point.location.latitude, + 'lon': point.location.longitude, + 'tsMillis': point.timestamp.millisecondsSinceEpoch, + }, + ) + .toList(), + }; + } + + /// Convert JSON to Contact + Contact? _contactFromJson(Map json) { + try { + return Contact( + publicKey: Uint8List.fromList( + base64Decode(json['publicKey'] as String), + ), + type: ContactType.fromValue(json['type'] as int), + flags: json['flags'] as int, + outPathLen: json['outPathLen'] as int, + outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)), + advName: json['advName'] as String, + nameOverride: json['nameOverride'] as String?, + lastAdvert: json['lastAdvert'] as int, + advLat: json['advLat'] as int, + advLon: json['advLon'] as int, + lastMod: json['lastMod'] as int, + telemetry: json['telemetry'] != null + ? _telemetryFromJson(json['telemetry'] as Map) + : null, + advertHistory: _advertHistoryFromJson(json['advertHistory']), + ); + } catch (e) { + debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e'); + return null; + } + } + + /// Convert ContactTelemetry to JSON + Map _telemetryToJson(ContactTelemetry telemetry) { + return { + 'gpsLocation': telemetry.gpsLocation != null + ? { + 'latitude': telemetry.gpsLocation!.latitude, + 'longitude': telemetry.gpsLocation!.longitude, + } + : null, + 'batteryPercentage': telemetry.batteryPercentage, + 'batteryMilliVolts': telemetry.batteryMilliVolts, + 'temperature': telemetry.temperature, + 'humidity': telemetry.humidity, + 'pressure': telemetry.pressure, + 'timestampMillis': telemetry.timestamp.millisecondsSinceEpoch, + 'extraSensorData': telemetry.extraSensorData, + }; + } + + /// Convert JSON to ContactTelemetry + ContactTelemetry? _telemetryFromJson(Map json) { + try { + return ContactTelemetry( + gpsLocation: json['gpsLocation'] != null + ? LatLng( + json['gpsLocation']['latitude'] as double, + json['gpsLocation']['longitude'] as double, + ) + : null, + batteryPercentage: json['batteryPercentage'] as double?, + batteryMilliVolts: json['batteryMilliVolts'] as double?, + temperature: json['temperature'] as double?, + humidity: json['humidity'] as double?, + pressure: json['pressure'] as double?, + timestamp: DateTime.fromMillisecondsSinceEpoch( + json['timestampMillis'] as int, + ), + extraSensorData: json['extraSensorData'] as Map?, + ); + } catch (e) { + debugPrint('❌ [ContactStorage] Error parsing telemetry from JSON: $e'); + return null; + } + } + + List _advertHistoryFromJson(dynamic json) { + if (json is! List) return []; + final result = []; + for (final item in json) { + if (item is! Map) continue; + final lat = item['lat']; + final lon = item['lon']; + final tsMillis = item['tsMillis']; + if (lat is! num || lon is! num || tsMillis is! int) continue; + result.add( + AdvertLocation( + location: LatLng(lat.toDouble(), lon.toDouble()), + timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis), + ), + ); + } + return result; + } + + Map _contactGroupToJson(SavedContactGroup group) { + return { + 'id': group.id, + 'sectionKey': group.sectionKey, + 'label': group.label, + 'query': group.query, + 'createdAtMillis': group.createdAt.millisecondsSinceEpoch, + 'matchPrefixes': group.matchPrefixes, + 'isAutoGroup': group.isAutoGroup, + }; + } + + SavedContactGroup? _contactGroupFromJson(Map json) { + try { + return SavedContactGroup( + id: json['id'] as String, + sectionKey: json['sectionKey'] as String, + label: json['label'] as String, + query: json['query'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch( + json['createdAtMillis'] as int, + ), + matchPrefixes: (json['matchPrefixes'] as List?) + ?.map((value) => value as String) + .toList(), + isAutoGroup: json['isAutoGroup'] as bool? ?? false, + ); + } catch (e) { + debugPrint('❌ [ContactStorage] Error parsing contact group: $e'); + return null; + } + } +} diff --git a/lib/services/developer_mode_service.dart b/lib/services/developer_mode_service.dart new file mode 100644 index 0000000..9ba5541 --- /dev/null +++ b/lib/services/developer_mode_service.dart @@ -0,0 +1,15 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class DeveloperModeService { + static const String _developerModeKey = 'developer_mode_enabled'; + + static Future isEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_developerModeKey) ?? false; + } + + static Future setEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_developerModeKey, enabled); + } +} diff --git a/lib/services/device_config_applicator.dart b/lib/services/device_config_applicator.dart new file mode 100644 index 0000000..c79dfec --- /dev/null +++ b/lib/services/device_config_applicator.dart @@ -0,0 +1,140 @@ +import 'dart:typed_data'; + +import '../models/channel.dart'; +import '../models/config_profile.dart'; +import '../providers/channels_provider.dart'; +import '../providers/connection_provider.dart'; + +class DeviceConfigApplicator { + ConfigProfileSections capture({ + required ConnectionProvider connectionProvider, + required ChannelsProvider channelsProvider, + }) { + final deviceInfo = connectionProvider.deviceInfo; + return ConfigProfileSections( + deviceConfig: DeviceConfigProfileSection( + frequencyKhz: deviceInfo.radioFreq, + bandwidth: deviceInfo.radioBw, + spreadingFactor: deviceInfo.radioSf, + codingRate: deviceInfo.radioCr, + repeatEnabled: deviceInfo.clientRepeat, + txPower: deviceInfo.txPower, + telemetryModes: deviceInfo.telemetryModes, + advertLocationPolicy: deviceInfo.advertLocPolicy, + multiAcks: deviceInfo.multiAcks, + manualAddContacts: deviceInfo.manualAddContacts, + autoAddUsers: deviceInfo.autoAddUsers, + autoAddRepeaters: deviceInfo.autoAddRepeaters, + autoAddRoomServers: deviceInfo.autoAddRoomServers, + autoAddSensors: deviceInfo.autoAddSensors, + autoAddOverwriteOldest: deviceInfo.autoAddOverwriteOldest, + publicLatitude: _decodeAdvertCoordinate(deviceInfo.advLat), + publicLongitude: _decodeAdvertCoordinate(deviceInfo.advLon), + ), + channels: channelsProvider.channels, + ); + } + + Future apply( + ConfigProfile profile, { + required ConnectionProvider connectionProvider, + required ChannelsProvider channelsProvider, + }) async { + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + + final deviceConfig = profile.sections.deviceConfig; + if (deviceConfig != null && !deviceConfig.isEmpty) { + if (deviceConfig.txPower != null) { + await connectionProvider.setTxPower(deviceConfig.txPower!); + } + if (deviceConfig.telemetryModes != null || + deviceConfig.advertLocationPolicy != null || + deviceConfig.manualAddContacts != null || + deviceConfig.multiAcks != null) { + await connectionProvider.setOtherParams( + manualAddContacts: (deviceConfig.manualAddContacts ?? false) ? 1 : 0, + telemetryModes: deviceConfig.telemetryModes ?? 0, + advertLocationPolicy: deviceConfig.advertLocationPolicy ?? 0, + multiAcks: deviceConfig.multiAcks ?? 0, + ); + } + if (deviceConfig.autoAddUsers != null && + deviceConfig.autoAddRepeaters != null && + deviceConfig.autoAddRoomServers != null && + deviceConfig.autoAddSensors != null && + deviceConfig.autoAddOverwriteOldest != null) { + await connectionProvider.setAutoaddConfig( + autoAddUsers: deviceConfig.autoAddUsers!, + autoAddRepeaters: deviceConfig.autoAddRepeaters!, + autoAddRoomServers: deviceConfig.autoAddRoomServers!, + autoAddSensors: deviceConfig.autoAddSensors!, + overwriteOldest: deviceConfig.autoAddOverwriteOldest!, + ); + } + if (deviceConfig.publicLatitude != null && + deviceConfig.publicLongitude != null) { + await connectionProvider.setAdvertLatLon( + latitude: deviceConfig.publicLatitude!, + longitude: deviceConfig.publicLongitude!, + ); + } + if (deviceConfig.frequencyKhz != null && + deviceConfig.bandwidth != null && + deviceConfig.spreadingFactor != null && + deviceConfig.codingRate != null) { + await connectionProvider.setRadioParams( + frequency: deviceConfig.frequencyKhz!, + bandwidth: deviceConfig.bandwidth!, + spreadingFactor: deviceConfig.spreadingFactor!, + codingRate: deviceConfig.codingRate!, + repeat: deviceConfig.repeatEnabled, + ); + } + } + + await _applyChannels( + channels: profile.sections.channels, + connectionProvider: connectionProvider, + channelsProvider: channelsProvider, + ); + await connectionProvider.refreshDeviceInfo(); + } + + Future _applyChannels({ + required List channels, + required ConnectionProvider connectionProvider, + required ChannelsProvider channelsProvider, + }) async { + if (channels.isEmpty) { + return; + } + + final desired = {for (final channel in channels) channel.index: channel}; + + for (final channel in channels) { + await connectionProvider.setChannelSlot( + channelIdx: channel.index, + channelName: channel.name, + secret: Uint8List.fromList(channel.secret), + ); + channelsProvider.addOrUpdateChannelObject(channel); + } + + for (final existing in channelsProvider.channels) { + if (existing.index == 0 || desired.containsKey(existing.index)) { + continue; + } + await connectionProvider.deleteChannel(existing.index); + channelsProvider.removeChannel(existing.index); + } + } + + double? _decodeAdvertCoordinate(int? value) { + if (value == null || value == 0) { + return null; + } + return value / 1e6; + } +} diff --git a/lib/services/gpx_service.dart b/lib/services/gpx_service.dart new file mode 100644 index 0000000..cb764a8 --- /dev/null +++ b/lib/services/gpx_service.dart @@ -0,0 +1,341 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:xml/xml.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:share_plus/share_plus.dart'; +import '../models/location_trail.dart'; + +/// Service for importing and exporting location trails in GPX format +class GpxService { + /// Export a LocationTrail to GPX 1.1 format + /// Returns the GPX content as a string + static String exportToGpx(LocationTrail trail, {String? customName}) { + final builder = XmlBuilder(); + + builder.processing('xml', 'version="1.0" encoding="UTF-8"'); + builder.element('gpx', nest: () { + // GPX attributes + builder.attribute('version', '1.1'); + builder.attribute('creator', 'MeshCore SAR'); + builder.attribute( + 'xmlns', + 'http://www.topografix.com/GPX/1/1', + ); + builder.attribute( + 'xmlns:xsi', + 'http://www.w3.org/2001/XMLSchema-instance', + ); + builder.attribute( + 'xsi:schemaLocation', + 'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd', + ); + + // Metadata section + builder.element('metadata', nest: () { + final name = customName ?? + 'MeshCore Trail - ${_formatDateTime(trail.startTime)}'; + builder.element('name', nest: () => builder.text(name)); + builder.element( + 'time', + nest: () => builder.text(trail.startTime.toIso8601String()), + ); + + // Add trail statistics in description + final distance = trail.totalDistance; + final duration = trail.duration; + final description = + 'Distance: ${_formatDistance(distance)}, ' + 'Duration: ${_formatDuration(duration)}, ' + 'Points: ${trail.points.length}'; + builder.element('desc', nest: () => builder.text(description)); + }); + + // Track section + builder.element('trk', nest: () { + final trackName = customName ?? 'MeshCore Trail'; + builder.element('name', nest: () => builder.text(trackName)); + + // Track segment with all points + builder.element('trkseg', nest: () { + for (final point in trail.points) { + builder.element('trkpt', nest: () { + builder.attribute('lat', point.position.latitude.toString()); + builder.attribute('lon', point.position.longitude.toString()); + + // Timestamp (required for proper GPX) + builder.element( + 'time', + nest: () => builder.text(point.timestamp.toIso8601String()), + ); + + // Elevation (optional, set to 0 if not available) + builder.element('ele', nest: () => builder.text('0')); + + // Extensions for additional data (accuracy, speed) + if (point.accuracy != null || point.speed != null) { + builder.element('extensions', nest: () { + if (point.accuracy != null) { + builder.element( + 'accuracy', + nest: () => builder.text(point.accuracy.toString()), + ); + } + if (point.speed != null) { + builder.element( + 'speed', + nest: () => builder.text(point.speed.toString()), + ); + } + }); + } + }); + } + }); + }); + }); + + final document = builder.buildDocument(); + return document.toXmlString(pretty: true, indent: ' '); + } + + /// Parse GPX content and return a LocationTrail + /// Throws FormatException if GPX is invalid + static LocationTrail importFromGpx(String gpxContent) { + try { + final document = XmlDocument.parse(gpxContent); + final gpxElement = document.findElements('gpx').firstOrNull; + + if (gpxElement == null) { + throw const FormatException('Invalid GPX file: Missing element'); + } + + // Extract track name from metadata or track element (currently unused, kept for future use) + // String? trackName; + // final metadataName = + // gpxElement.findElements('metadata').firstOrNull?.findElements('name').firstOrNull?.innerText; + // final trackNameElement = gpxElement + // .findElements('trk') + // .firstOrNull + // ?.findElements('name') + // .firstOrNull + // ?.innerText; + // trackName = metadataName ?? trackNameElement ?? 'Imported Trail'; + + // Extract track points + final trackPoints = []; + final tracks = gpxElement.findElements('trk'); + + if (tracks.isEmpty) { + throw const FormatException( + 'Invalid GPX file: No elements found', + ); + } + + // Process first track only + final track = tracks.first; + final segments = track.findElements('trkseg'); + + for (final segment in segments) { + final trkpts = segment.findElements('trkpt'); + + for (final trkpt in trkpts) { + try { + // Extract latitude and longitude (required) + final latStr = trkpt.getAttribute('lat'); + final lonStr = trkpt.getAttribute('lon'); + + if (latStr == null || lonStr == null) { + debugPrint('⚠️ Skipping track point: Missing lat/lon attributes'); + continue; + } + + final lat = double.parse(latStr); + final lon = double.parse(lonStr); + + // Extract timestamp (optional) + final timeStr = + trkpt.findElements('time').firstOrNull?.innerText; + final timestamp = timeStr != null + ? DateTime.parse(timeStr) + : DateTime.now(); + + // Extract elevation (optional, currently unused but parsed for future use) + // final eleStr = trkpt.findElements('ele').firstOrNull?.innerText; + // final elevation = eleStr != null ? double.tryParse(eleStr) : null; + + // Extract extensions (accuracy, speed) + double? accuracy; + double? speed; + final extensions = + trkpt.findElements('extensions').firstOrNull; + if (extensions != null) { + final accuracyStr = + extensions.findElements('accuracy').firstOrNull?.innerText; + final speedStr = + extensions.findElements('speed').firstOrNull?.innerText; + accuracy = accuracyStr != null ? double.tryParse(accuracyStr) : null; + speed = speedStr != null ? double.tryParse(speedStr) : null; + } + + // Create trail point + trackPoints.add( + TrailPoint( + position: LatLng(lat, lon), + timestamp: timestamp, + accuracy: accuracy, + speed: speed, + ), + ); + } catch (e) { + debugPrint('⚠️ Error parsing track point: $e'); + // Continue with next point + } + } + } + + if (trackPoints.isEmpty) { + throw const FormatException( + 'Invalid GPX file: No valid track points found', + ); + } + + // Create LocationTrail from parsed points + final startTime = trackPoints.first.timestamp; + final endTime = trackPoints.last.timestamp; + + return LocationTrail( + id: 'imported_${DateTime.now().millisecondsSinceEpoch}', + points: trackPoints, + startTime: startTime, + endTime: endTime, + isActive: false, + ); + } on XmlException catch (e) { + throw FormatException('Invalid GPX XML: ${e.message}'); + } catch (e) { + throw FormatException('Failed to parse GPX file: $e'); + } + } + + /// Export trail to file and trigger system share sheet + /// Returns true if successful + static Future exportTrailToFile( + LocationTrail trail, { + String? customName, + }) async { + try { + // Generate GPX content + debugPrint('📤 Generating GPX content...'); + final gpxContent = exportToGpx(trail, customName: customName); + + // Create filename with timestamp + final timestamp = DateTime.now(); + final filename = + 'meshcore_trail_${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')}_${timestamp.hour.toString().padLeft(2, '0')}${timestamp.minute.toString().padLeft(2, '0')}${timestamp.second.toString().padLeft(2, '0')}.gpx'; + + // Save to temporary directory + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/$filename'); + await file.writeAsString(gpxContent); + + debugPrint('📤 GPX file saved: ${file.path}'); + debugPrint('📤 File size: ${file.lengthSync()} bytes'); + + // Share the file using system share sheet + final result = await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path, mimeType: 'application/gpx+xml')], + subject: 'MeshCore Trail Export', + text: 'MeshCore SAR location trail (${trail.points.length} points)', + ), + ); + + debugPrint('📤 Share result: ${result.status}'); + return result.status == ShareResultStatus.success || + result.status == ShareResultStatus.unavailable; // unavailable = user dismissed, still OK + + } catch (e) { + debugPrint('❌ Failed to export trail: $e'); + return false; + } + } + + /// Import trail from GPX file using file picker + /// Returns LocationTrail if successful, null if cancelled or failed + static Future importTrailFromFile() async { + try { + // Open file picker for GPX files + debugPrint('📥 Opening file picker for GPX import...'); + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['gpx'], + allowMultiple: false, + ); + + if (result == null || result.files.isEmpty) { + debugPrint('📥 Import cancelled by user'); + return null; + } + + final file = result.files.first; + debugPrint('📥 Selected file: ${file.name}'); + debugPrint('📥 File size: ${file.size} bytes'); + + // Read file content + String gpxContent; + if (file.path != null) { + // File has path (mobile) + gpxContent = await File(file.path!).readAsString(); + } else if (file.bytes != null) { + // File has bytes (web) + gpxContent = String.fromCharCodes(file.bytes!); + } else { + throw Exception('Unable to read file content'); + } + + // Parse GPX content + debugPrint('📥 Parsing GPX content...'); + final trail = importFromGpx(gpxContent); + debugPrint( + '✅ Successfully imported trail: ${trail.points.length} points', + ); + + return trail; + } catch (e) { + debugPrint('❌ Failed to import trail: $e'); + rethrow; + } + } + + // Helper: Format distance for display + static String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.toStringAsFixed(0)} m'; + } else { + return '${(meters / 1000).toStringAsFixed(2)} km'; + } + } + + // Helper: Format duration for display + static String _formatDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m ${seconds}s'; + } else if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } else { + return '${seconds}s'; + } + } + + // Helper: Format DateTime for filename + static String _formatDateTime(DateTime dt) { + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/services/image_codec_service.dart b/lib/services/image_codec_service.dart new file mode 100644 index 0000000..ed09ca7 --- /dev/null +++ b/lib/services/image_codec_service.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; +import 'package:flutter_avif/flutter_avif.dart'; + +/// Compresses and resizes an image for low-bandwidth mesh transmission. +/// +/// Target: ≤256×256 pixels, grayscale AVIF at aggressive quality. +/// A typical 256×256 grayscale AVIF at quality 90 is highly compressed. +/// → 7–20 fragments at 152 bytes each. +class ImageCodecService { + static const int _normalAvifSpeed = 6; + static const int _ultraAvifSpeed = 4; + + /// Compress [rawBytes] (any decodable format: JPEG/PNG/WebP/AVIF) to a + /// small grayscale AVIF suitable for mesh transmission. + /// + /// [maxDimension] caps width and height (default 256); aspect ratio is + /// preserved and images smaller than the cap are not upscaled. + /// [compression] 0 = lossless, 100 = smallest/worst (libavif CQ scale). + /// + /// Returns `(bytes, width, height)` or null if decoding or encoding fails. + static Future<({Uint8List bytes, int width, int height})?> compress( + Uint8List rawBytes, { + int maxDimension = 256, + int compression = 90, + bool grayscale = true, + bool ultraMode = false, + }) async { + try { + final effectiveMaxDimension = maxDimension.clamp(32, 1024); + final effectiveCompression = ultraMode + ? (compression + 12).clamp(10, 100) + : compression.clamp(10, 100); + final forceGrayscale = ultraMode ? true : grayscale; + + // 1a. Probe original dimensions (no resize). + final probeCodec = await ui.instantiateImageCodec(rawBytes); + final probeFrame = await probeCodec.getNextFrame(); + final srcW = probeFrame.image.width; + final srcH = probeFrame.image.height; + probeFrame.image.dispose(); + + // 1b. Compute contain dimensions: scale down only the limiting axis so + // the image fits within maxDimension×maxDimension without stretching. + int dstW = srcW; + int dstH = srcH; + if (srcW > effectiveMaxDimension || srcH > effectiveMaxDimension) { + if (srcW >= srcH) { + dstW = effectiveMaxDimension; + dstH = (srcH * effectiveMaxDimension / srcW).round().clamp( + 1, + effectiveMaxDimension, + ); + } else { + dstH = effectiveMaxDimension; + dstW = (srcW * effectiveMaxDimension / srcH).round().clamp( + 1, + effectiveMaxDimension, + ); + } + } + + // 1c. Decode at the exact contain size (single axis constrained). + final codec = await ui.instantiateImageCodec( + rawBytes, + targetWidth: dstW, + targetHeight: dstH, + allowUpscaling: false, + ); + final frame = await codec.getNextFrame(); + final image = frame.image; + + final w = image.width; + final h = image.height; + + // 2. Export RGBA pixels. + final byteData = await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + image.dispose(); + if (byteData == null) return null; + + // 3. Optionally convert to grayscale in-place (luminance). + final rgba = byteData.buffer.asUint8List(); + if (forceGrayscale) { + for (var i = 0; i < rgba.length; i += 4) { + final lum = + (0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]) + .round() + .clamp(0, 255); + rgba[i] = lum; + rgba[i + 1] = lum; + rgba[i + 2] = lum; + rgba[i + 3] = 255; + } + } + + // 4. Re-encode grayscale RGBA → PNG so encodeAvif can decode it. + // encodeAvif() takes an encoded image (PNG/JPEG), not raw RGBA. + final buffer = await ui.ImmutableBuffer.fromUint8List(rgba); + final descriptor = ui.ImageDescriptor.raw( + buffer, + width: w, + height: h, + pixelFormat: ui.PixelFormat.rgba8888, + ); + final greyCodec = await descriptor.instantiateCodec(); + final greyFrame = await greyCodec.getNextFrame(); + final greyImage = greyFrame.image; + final pngData = await greyImage.toByteData( + format: ui.ImageByteFormat.png, + ); + greyImage.dispose(); + if (pngData == null) return null; + final pngBytes = pngData.buffer.asUint8List(); + + // 5. Encode PNG → AVIF. + // maxQuantizer/minQuantizer: libavif CQ scale (0 = lossless, 63 = worst). + // compression=90 maps to maxQuantizer≈57, minQuantizer≈37. + final maxQ = ((effectiveCompression / 100) * 63).round().clamp(0, 63); + final minQ = (maxQ * 0.65).round().clamp(0, maxQ); + final avif = await encodeAvif( + pngBytes, + maxThreads: 2, + maxQuantizer: maxQ, + minQuantizer: minQ, + // We force fully opaque alpha, so alpha can be quantized aggressively. + maxQuantizerAlpha: 63, + minQuantizerAlpha: 63, + // Slower speed improves compression efficiency at similar quality. + speed: ultraMode ? _ultraAvifSpeed : _normalAvifSpeed, + keepExif: false, + ); + if (avif.isEmpty) return null; + + debugPrint( + '📷 [ImageCodec] ${rawBytes.length}B → $w×$h grayscale AVIF ' + '${avif.length}B (${(avif.length * 100 / rawBytes.length).round()}%)', + ); + return (bytes: avif, width: w, height: h); + } catch (e, st) { + debugPrint('❌ [ImageCodec] compress error: $e\n$st'); + return null; + } + } +} diff --git a/lib/services/image_preferences.dart b/lib/services/image_preferences.dart new file mode 100644 index 0000000..c09d943 --- /dev/null +++ b/lib/services/image_preferences.dart @@ -0,0 +1,76 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'profiles_feature_service.dart'; + +/// Stores user-selected image compression settings. +class ImagePreferences { + static const String _maxSizeKey = 'image_max_size'; + // Keep the legacy key name so existing users retain their saved value. + static const String _qualityKey = 'image_quality'; + static const String _grayscaleKey = 'image_grayscale'; + static const String _ultraModeKey = 'image_ultra_mode'; + + static const int defaultMaxSize = 256; + static const int defaultQuality = 90; + static const bool defaultGrayscale = true; + static const bool defaultUltraMode = false; + + static const List supportedSizes = [64, 96, 128, 256]; + + static Future getMaxSize() async { + final prefs = await SharedPreferences.getInstance(); + final value = + prefs.getInt(ProfileStorageScope.scopedKey(_maxSizeKey)) ?? + defaultMaxSize; + return supportedSizes.contains(value) ? value : defaultMaxSize; + } + + static Future setMaxSize(int size) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(ProfileStorageScope.scopedKey(_maxSizeKey), size); + } + + static Future getCompression() async { + final prefs = await SharedPreferences.getInstance(); + final value = + prefs.getInt(ProfileStorageScope.scopedKey(_qualityKey)) ?? + defaultQuality; + return value.clamp(10, 90); + } + + static Future setCompression(int compression) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt( + ProfileStorageScope.scopedKey(_qualityKey), + compression.clamp(10, 90), + ); + } + + static Future getGrayscale() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(ProfileStorageScope.scopedKey(_grayscaleKey)) ?? + defaultGrayscale; + } + + static Future setGrayscale(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(ProfileStorageScope.scopedKey(_grayscaleKey), value); + } + + static Future getUltraMode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(ProfileStorageScope.scopedKey(_ultraModeKey)) ?? + defaultUltraMode; + } + + static Future setUltraMode(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(ProfileStorageScope.scopedKey(_ultraModeKey), value); + } + + static int effectiveMaxSize( + int configuredMaxSize, { + required bool ultraMode, + }) { + return configuredMaxSize.clamp(32, 1024); + } +} diff --git a/lib/services/live_traffic_summary.dart b/lib/services/live_traffic_summary.dart new file mode 100644 index 0000000..8e7b27d --- /dev/null +++ b/lib/services/live_traffic_summary.dart @@ -0,0 +1,359 @@ +import '../models/ble_packet_log.dart'; +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 _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 ": ".', + ), + 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; + + String get payloadLabel { + final decodedRoute = route; + if (decodedRoute == null) { + return log.responseCode != null ? log.opcodeName : 'Unknown'; + } + return payloadTypeLabel(decodedRoute.payloadType); + } + + String? get payloadMeaning { + final decodedRoute = route; + if (decodedRoute == null) return null; + return payloadTypeMeaning(decodedRoute.payloadType); + } + + String get routePreview { + final decodedRoute = route; + if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) { + return 'Direct packet'; + } + return decodedRoute.hopHashes + .map((hashHex) => '0x${hashHex.toUpperCase()}') + .join(' -> '); + } + + static List 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) { + return payloadTypeDetails(payloadType).summary; + } + + static String payloadTypeDescription(int payloadType) { + return payloadTypeDetails(payloadType).description; + } +} + +class LiveTrafficSnapshot { + final DateTime windowStart; + final Duration windowDuration; + final int packetsPerMinute; + final int rxCount; + final int txCount; + final int totalCount; + final double? avgSnrDb; + final double? latestSnrDb; + final double? avgRssiDbm; + final int? latestRssiDbm; + final int multiHopCount; + final double? avgHopCount; + final List visibleEntries; + final LiveTrafficBusyness busyness; + + const LiveTrafficSnapshot({ + required this.windowStart, + required this.windowDuration, + required this.packetsPerMinute, + required this.rxCount, + required this.txCount, + required this.totalCount, + required this.avgSnrDb, + required this.latestSnrDb, + required this.avgRssiDbm, + required this.latestRssiDbm, + required this.multiHopCount, + required this.avgHopCount, + required this.visibleEntries, + required this.busyness, + }); +} + +class LiveTrafficSummary { + static const Duration rollingWindow = Duration(seconds: 60); + static const int maxVisibleEntries = 120; + static const int logRxDataResponseCode = 0x88; + + const LiveTrafficSummary._(); + + static bool isRxDataLog(BlePacketLog log) { + return log.direction == PacketDirection.rx && + log.responseCode == logRxDataResponseCode; + } + + static int countRxDataLogs(Iterable logs, {DateTime? since}) { + return logs.where((log) { + return isRxDataLog(log) && + (since == null || !log.timestamp.isBefore(since)); + }).length; + } + + static LiveTrafficSnapshot fromLogs( + Iterable logs, { + required DateTime now, + DateTime? clearedAt, + int? preferredHashSize, + Duration window = rollingWindow, + String? packetTypeFilter, + }) { + final windowStart = now.subtract(window); + final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart) + ? clearedAt + : windowStart; + + final recentLogs = + logs + .where( + (log) => + isRxDataLog(log) && !log.timestamp.isBefore(effectiveStart), + ) + .toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + + final entries = []; + for (final log in recentLogs) { + final route = LogRxRouteDecoder.decode( + log.rawData, + preferredHashSize: preferredHashSize, + ); + entries.add(LiveTrafficEntry(log: log, route: route)); + } + + final filteredEntries = packetTypeFilter == null + ? entries + : entries + .where((entry) => entry.payloadLabel == packetTypeFilter) + .toList(); + + var rxCount = 0; + var snrCount = 0; + var snrSum = 0.0; + var rssiCount = 0; + var rssiSum = 0.0; + double? latestSnrDb; + int? latestRssiDbm; + var multiHopCount = 0; + var hopCountTotal = 0; + var hopCountSamples = 0; + + for (final entry in filteredEntries) { + rxCount += 1; + + final rxInfo = entry.log.logRxDataInfo; + if (rxInfo?.snrDb != null) { + snrCount += 1; + snrSum += rxInfo!.snrDb!; + latestSnrDb = rxInfo.snrDb!; + } + if (rxInfo?.rssiDbm != null) { + rssiCount += 1; + rssiSum += rxInfo!.rssiDbm!.toDouble(); + latestRssiDbm = rxInfo.rssiDbm!; + } + + final route = entry.route; + if (route != null && route.hopCount > 0) { + hopCountSamples += 1; + hopCountTotal += route.hopCount; + if (route.hopCount > 1) { + multiHopCount += 1; + } + } + } + + final visibleEntries = filteredEntries.reversed + .take(maxVisibleEntries) + .toList(); + const txCount = 0; + final totalCount = rxCount; + final packetsPerMinute = + ((totalCount * Duration.secondsPerMinute) / window.inSeconds).round(); + + return LiveTrafficSnapshot( + windowStart: effectiveStart, + windowDuration: window, + packetsPerMinute: packetsPerMinute, + rxCount: rxCount, + txCount: txCount, + totalCount: totalCount, + avgSnrDb: snrCount == 0 ? null : snrSum / snrCount, + latestSnrDb: latestSnrDb, + avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount, + latestRssiDbm: latestRssiDbm, + multiHopCount: multiHopCount, + avgHopCount: hopCountSamples == 0 + ? null + : hopCountTotal / hopCountSamples, + visibleEntries: visibleEntries, + busyness: _busynessForPacketsPerMinute(packetsPerMinute), + ); + } + + static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) { + if (ppm <= 5) return LiveTrafficBusyness.quiet; + if (ppm <= 20) return LiveTrafficBusyness.active; + return LiveTrafficBusyness.busy; + } +} diff --git a/lib/services/locale_preferences.dart b/lib/services/locale_preferences.dart new file mode 100644 index 0000000..aff4312 --- /dev/null +++ b/lib/services/locale_preferences.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Service for managing locale preferences +class LocalePreferences { + static const String _localeKey = 'app_locale'; + + /// Supported locales + static const List supportedLocales = [ + Locale('en'), // English + Locale('sl'), // Slovenian + Locale('hr'), // Croatian + Locale('de'), // German + Locale('es'), // Spanish + Locale('fr'), // French + Locale('it'), // Italian + Locale('el'), // Greek + Locale('pt'), // Portuguese + Locale('tr'), // Turkish + Locale('pl'), // Polish + Locale('uk'), // Ukrainian + Locale('ru'), // Russian + Locale('zh'), // Chinese + ]; + + /// Get the saved locale or return null to use system locale + static Future getLocale() async { + final prefs = await SharedPreferences.getInstance(); + final localeCode = prefs.getString(_localeKey); + + if (localeCode == null) { + return null; // Use system locale + } + + return Locale(localeCode); + } + + /// Save the selected locale + static Future setLocale(Locale? locale) async { + final prefs = await SharedPreferences.getInstance(); + + if (locale == null) { + // Remove preference to use system locale + await prefs.remove(_localeKey); + } else { + await prefs.setString(_localeKey, locale.languageCode); + } + } + + /// Get display name for a locale + static String getDisplayName(Locale? locale) { + if (locale == null) { + return 'System Default'; + } + + switch (locale.languageCode) { + case 'en': + return 'English'; + case 'sl': + return 'Slovenščina'; + case 'hr': + return 'Hrvatski'; + case 'de': + return 'Deutsch'; + case 'es': + return 'Español'; + case 'fr': + return 'Français'; + case 'it': + return 'Italiano'; + case 'el': + return 'Greek'; + case 'pt': + return 'Portuguese'; + case 'tr': + return 'Turkish'; + case 'pl': + return 'Polish'; + case 'uk': + return 'Ukrainian'; + case 'ru': + return 'Русский'; + case 'zh': + return '简体中文'; + default: + return locale.languageCode; + } + } + + /// Get native display name for a locale (shown in selection dialog) + static String getNativeDisplayName(Locale locale) { + switch (locale.languageCode) { + case 'en': + return 'English'; + case 'sl': + return 'Slovenščina'; + case 'hr': + return 'Hrvatski'; + case 'de': + return 'Deutsch'; + case 'es': + return 'Español'; + case 'fr': + return 'Français'; + case 'it': + return 'Italiano'; + case 'el': + return 'Ελληνικά'; + case 'pt': + return 'Português'; + case 'tr': + return 'Türkçe'; + case 'pl': + return 'Polski'; + case 'uk': + return 'Українська'; + case 'ru': + return 'Русский'; + case 'zh': + return '简体中文'; + default: + return locale.languageCode; + } + } +} diff --git a/lib/services/location_tracking_service.dart b/lib/services/location_tracking_service.dart new file mode 100644 index 0000000..e834be7 --- /dev/null +++ b/lib/services/location_tracking_service.dart @@ -0,0 +1,1003 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:meshcore_client/meshcore_client.dart'; +import 'profiles_feature_service.dart'; + +/// Centralized location tracking service for MeshCore SAR +/// +/// Handles GPS tracking, distance thresholds, background updates, +/// and location broadcasting to the mesh network. +/// +/// Features: +/// - Singleton pattern for app-wide access +/// - Configurable distance thresholds (min/max) +/// - Configurable time intervals +/// - Permission handling +/// - SharedPreferences persistence +/// - 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; + // Moving cadence tiers — identical to the MeshUI firmware + // (FAST_GPS_*_INTERVAL_MS / FAST_GPS_SPEED_*_MAX_MPS in MyMesh.cpp). + 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; + + // Stationary keepalive — a parked node still beacons, but only every 9 min + // (FAST_GPS_STATIONARY_*_INTERVAL_MS), not on GPS jitter. + static const Duration _fastLocationStationaryInterval = Duration(minutes: 9); + + // Moving/stationary detection: anchor + dwell hysteresis (position-only, no + // Doppler), mirroring FAST_GPS_MOVE_* in MyMesh.cpp. A parked node holds an + // anchor; jitter inside the radius is absorbed and the stable anchor is + // reported, so multipath wander can't paint a fake track. + static const double _fastLocationMoveRadiusMeters = 25.0; + static const Duration _fastLocationMoveDwell = Duration(seconds: 15); + static const Duration _fastLocationStopDwell = Duration(seconds: 60); + static const Duration _fastLocationMoveEval = Duration(seconds: 1); + static const double _fastLocationSpeedGateMeters = 5.0; + static const Duration _fastLocationSpeedRebaseline = Duration(seconds: 8); + static const double _fastLocationSpeedGlitchMaxKmh = 300.0; + + // Fix-quality gate. The firmware requires >= 5 satellites (FAST_GPS_MIN_SATS); + // Geolocator does not expose a satellite count on all platforms, so we gate on + // horizontal accuracy as the closest available proxy. + static const double _fastLocationMaxAccuracyMeters = 50.0; + + // Yield ~5s after hearing a peer beacon, to avoid channel collisions + // (FAST_GPS_CHANNEL_RX_HOLDOFF_MS). + static const Duration _fastLocationRxHoldoff = Duration(seconds: 5); + + // Background keepalive tick when not in active use — cheap, just enough to fire + // the 9-min stationary beacon while parked. + static const Duration _fastLocationActiveTick = Duration(seconds: 15); + // ============================================================================ + // Singleton Pattern + // ============================================================================ + + static final LocationTrackingService _instance = + LocationTrackingService._internal(); + + /// Get the singleton instance + factory LocationTrackingService() => _instance; + + LocationTrackingService._internal(); + + // ============================================================================ + // SharedPreferences Keys + // ============================================================================ + + static const String _prefKeyEnabled = 'background_tracking_enabled'; + static const String _prefKeyMinDistance = 'map_gps_min_distance'; + static const String _prefKeyMaxDistance = 'map_gps_max_distance'; + static const String _prefKeyMinTimeInterval = 'map_gps_min_time_interval'; + static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance'; + static const String _prefKeyLastLat = 'background_last_lat'; + static const String _prefKeyLastLon = 'background_last_lon'; + static const String _prefKeyFastLocationEnabled = + 'fast_location_updates_enabled'; + static const String _prefKeyFastMovementThreshold = + 'fast_location_movement_threshold_meters'; + static const String _prefKeyFastActiveCadence = + 'fast_location_active_cadence_seconds'; + static const String _prefKeyFastChannelIdx = 'fast_location_channel_idx'; + + String _scopedKey(String baseKey) { + return ProfileStorageScope.scopedKey(baseKey); + } + + // ============================================================================ + // Configuration Properties + // ============================================================================ + + /// Minimum distance in meters before broadcasting update + double minDistanceMeters = 5.0; + + /// Maximum distance in meters that forces a broadcast regardless of time + double maxDistanceMeters = 100.0; + + /// Minimum time interval in seconds between broadcasts + int minTimeIntervalSeconds = 30; + + /// GPS update distance filter for position stream + double gpsUpdateDistance = 10.0; + + /// Whether private fast GPS updates are enabled + bool fastLocationUpdatesEnabled = false; + + /// Distance threshold for fast GPS updates + double fastLocationMovementThresholdMeters = + _defaultFastLocationMovementThresholdMeters; + + /// Cadence for active-use fast GPS updates + int fastLocationActiveCadenceSeconds = + _defaultFastLocationActiveCadenceSeconds; + + /// Target channel index for fast GPS updates; null means disabled/unset. + int? fastLocationChannelIdx; + + // ============================================================================ + // State Properties + // ============================================================================ + + /// Current GPS position + Position? currentPosition; + + /// Whether tracking is currently active + bool isTracking = false; + + /// Whether service has been initialized with BLE service + bool _isInitialized = false; + + /// Whether the first stable position has been set (without broadcast) + bool _firstPositionSet = false; + + // ============================================================================ + // Private Properties + // ============================================================================ + + /// Reference to MeshCore BLE service for broadcasting + MeshCoreBleService? _bleService; + + /// Position stream subscription + StreamSubscription? _positionSubscription; + + Timer? _fastLocationTimer; + bool _isFastLocationActiveUse = false; + DateTime? _lastFastLocationSentAt; + int? _lastFastLocationSentLatE6; + int? _lastFastLocationSentLonE6; + + // Firmware-mirrored fast-GPS motion state (app-fallback path). See + // MyMesh::updateGpsStatusCache / maybeSendFastGpsUpdate. + int? _fastGpsAnchorLatE6; + int? _fastGpsAnchorLonE6; + bool _fastGpsRefValid = false; + bool _fastGpsIsMoving = false; + DateTime? _fastGpsMoveStateSince; + DateTime? _fastGpsMoveEvalAt; + double _fastGpsSpeedKmh = 0.0; + int? _fastGpsSpeedPrevLatE6; + int? _fastGpsSpeedPrevLonE6; + DateTime? _fastGpsSpeedPrevAt; + DateTime? _fastGpsRxHoldoffUntil; + + // ============================================================================ + // Callback Properties + // ============================================================================ + + /// Called when position is updated + void Function(Position)? onPositionUpdate; + + /// Called when an error occurs + void Function(String error)? onError; + + /// Called when a location broadcast is sent to mesh network + void Function(Position)? onBroadcastSent; + + /// Called when tracking state changes + void Function(bool isTracking)? onTrackingStateChanged; + + /// Called when a fast private GPS update should be sent. Carries the resolved + /// beacon coordinates (de-jittered anchor when parked) and EMA ground speed in + /// km/h, so the caller just encodes and transmits. + void Function(double latitude, double longitude, int speedKmh, String reason)? + onFastLocationUpdate; + + // ============================================================================ + // Initialization + // ============================================================================ + + /// Initialize the service with MeshCore BLE service reference + /// + /// Must be called before starting tracking. + Future initialize(MeshCoreBleService bleService) async { + _bleService = bleService; + _isInitialized = true; + + // Load saved settings + await loadSettings(); + + debugPrint('✅ [LocationTracking] Service initialized'); + return true; + } + + // ============================================================================ + // Permission Handling + // ============================================================================ + + /// Check if location permissions are granted + Future checkPermissions() async { + final permission = await Geolocator.checkPermission(); + if (Platform.isIOS) { + return permission == LocationPermission.always; + } + + return permission == LocationPermission.always || + permission == LocationPermission.whileInUse; + } + + /// Request location permissions from user + /// + /// Returns true if granted, false otherwise. + Future requestPermissions() async { + // Check if location service is enabled + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + onError?.call('Location services are disabled'); + return false; + } + + // Check current permission + LocationPermission permission = await Geolocator.checkPermission(); + + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + onError?.call('Location permission denied'); + return false; + } + } + + if (permission == LocationPermission.deniedForever) { + onError?.call( + 'Location permission permanently denied. Please enable in settings.', + ); + return false; + } + + if (Platform.isIOS && permission != LocationPermission.always) { + permission = await Geolocator.requestPermission(); + if (permission != LocationPermission.always) { + onError?.call( + 'Background tracking on iOS requires "Always" location access. Enable it in Settings.', + ); + return false; + } + } + + debugPrint('✅ [LocationTracking] Location permissions granted'); + return true; + } + + // ============================================================================ + // GPS Position Methods + // ============================================================================ + + /// Get current GPS position + /// + /// Returns null if position unavailable or permissions denied. + /// [timeLimit] - Maximum time to wait for position (default: 15 seconds) + /// [retryCount] - Number of retry attempts (default: 2) + Future getCurrentPosition({ + Duration timeLimit = const Duration(seconds: 15), + int retryCount = 2, + }) async { + for (int attempt = 0; attempt <= retryCount; attempt++) { + try { + if (attempt > 0) { + debugPrint( + '🔄 [LocationTracking] Retry attempt $attempt/$retryCount', + ); + // Exponential backoff: wait 2^attempt seconds before retry + await Future.delayed(Duration(seconds: 1 << attempt)); + } + + final position = await Geolocator.getCurrentPosition( + locationSettings: _buildLocationSettings( + timeLimit: timeLimit, + distanceFilter: 0, + ), + ); + + currentPosition = position; + if (attempt > 0) { + debugPrint( + '✅ [LocationTracking] Position acquired after $attempt retries', + ); + } + return position; + } catch (e) { + final isLastAttempt = attempt == retryCount; + if (isLastAttempt) { + debugPrint( + '❌ [LocationTracking] Failed to get position after $retryCount retries: $e', + ); + // Only call error callback on final failure, and make it user-friendly + if (e.toString().contains('TimeoutException')) { + onError?.call( + 'GPS signal weak. Position stream will continue trying...', + ); + } else { + onError?.call('Failed to get GPS position. Check device settings.'); + } + } else { + debugPrint( + '⚠️ [LocationTracking] Position attempt $attempt failed: $e', + ); + } + + if (isLastAttempt) { + return null; + } + } + } + return null; + } + + /// Get position stream with configurable distance filter + /// + /// [distanceFilter] - Minimum distance in meters between position updates + Stream getPositionStream({double distanceFilter = 10.0}) { + return Geolocator.getPositionStream( + locationSettings: _buildLocationSettings( + distanceFilter: distanceFilter.toInt(), + ), + ); + } + + LocationSettings _buildLocationSettings({ + int distanceFilter = 10, + Duration? timeLimit, + }) { + if (Platform.isIOS) { + return AppleSettings( + accuracy: LocationAccuracy.best, + activityType: ActivityType.fitness, + allowBackgroundLocationUpdates: true, + distanceFilter: distanceFilter, + pauseLocationUpdatesAutomatically: false, + showBackgroundLocationIndicator: true, + timeLimit: timeLimit, + ); + } + + return LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: distanceFilter, + timeLimit: timeLimit, + ); + } + + // ============================================================================ + // Tracking Control + // ============================================================================ + + /// Start location tracking + /// + /// [distanceThreshold] - GPS update distance filter + /// + /// Returns true if successful, false otherwise. + /// Note: This method returns immediately after starting the position stream. + /// Initial position acquisition happens asynchronously in the background. + /// + /// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped. + Future startTracking({double? distanceThreshold}) async { + if (!_isInitialized) { + debugPrint('⚠️ [LocationTracking] Service not initialized'); + onError?.call('Location tracking service not initialized'); + return false; + } + + // Allow tracking without BLE connection - broadcasts will be skipped + if (_bleService == null || !_bleService!.isConnected) { + debugPrint( + 'ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)', + ); + } + + // Check permissions + final hasPermission = await requestPermissions(); + if (!hasPermission) { + return false; + } + + // Use provided threshold or current setting + final threshold = distanceThreshold ?? gpsUpdateDistance; + gpsUpdateDistance = threshold; + + // Save settings + await saveSettings(); + + // Try to get initial position in background (non-blocking) + // This will populate currentPosition but won't block tracking startup + getCurrentPosition(timeLimit: const Duration(seconds: 10), retryCount: 1) + .then((position) { + if (position != null) { + debugPrint( + '✅ [LocationTracking] Initial position acquired in background', + ); + } + }) + .catchError((error) { + debugPrint( + '⚠️ [LocationTracking] Background initial position failed: $error', + ); + // Not critical - position stream will eventually provide position + }); + + // Start position stream immediately (don't wait for initial position) + try { + _positionSubscription = getPositionStream(distanceFilter: threshold) + .listen( + _handlePositionUpdate, + onError: (error) { + debugPrint('❌ [LocationTracking] Position stream error: $error'); + onError?.call('GPS stream error. Retrying...'); + }, + ); + + isTracking = true; + onTrackingStateChanged?.call(true); + _refreshFastLocationTimer(); + + debugPrint( + '✅ [LocationTracking] Tracking started with ${threshold}m threshold', + ); + debugPrint('📡 [LocationTracking] Waiting for GPS signal...'); + return true; + } catch (e) { + debugPrint('❌ [LocationTracking] Failed to start tracking: $e'); + onError?.call('Failed to start GPS tracking: $e'); + return false; + } + } + + /// Stop location tracking + Future stopTracking() async { + debugPrint('🛑 [LocationTracking] Stopping tracking'); + + await _positionSubscription?.cancel(); + _positionSubscription = null; + + isTracking = false; + onTrackingStateChanged?.call(false); + _refreshFastLocationTimer(); + _resetFastGpsMotionState(); + + // Reset first position flag so next connection starts fresh + _firstPositionSet = false; + + // Save disabled state + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_scopedKey(_prefKeyEnabled), false); + + debugPrint('✅ [LocationTracking] Tracking stopped'); + } + + /// Update the distance threshold and restart tracking if active + Future updateDistanceThreshold(double meters) async { + gpsUpdateDistance = meters; + await saveSettings(); + + debugPrint( + '📏 [LocationTracking] Distance threshold updated to ${meters}m', + ); + + // Restart tracking if currently active + if (isTracking) { + await stopTracking(); + await startTracking(distanceThreshold: meters); + } + } + + // ============================================================================ + // Position Update Handler + // ============================================================================ + + /// Handle incoming position updates from GPS stream + void _handlePositionUpdate(Position position) { + debugPrint( + '📍 [LocationTracking] New position: ${position.latitude}, ${position.longitude}', + ); + + // Update current position + currentPosition = position; + + // Notify listeners + onPositionUpdate?.call(position); + + _evaluateFastLocationMovement(position); + + // SPECIAL CASE: First stable position after connection + // Set lat/lon on device WITHOUT broadcasting to mesh network + if (!_firstPositionSet) { + _setInitialPosition(position); + return; + } + + // Check if we should broadcast to mesh network + _checkAndBroadcast(position); + } + + /// Set initial position on device without broadcasting + /// + /// Called only for the first stable GPS position after connection starts. + /// Updates the device's advertised lat/lon but does NOT send an advertisement. + void _setInitialPosition(Position position) async { + if (_bleService == null || !_bleService!.isConnected) { + debugPrint( + '⚠️ [LocationTracking] Cannot set initial position: BLE not connected', + ); + return; + } + + try { + debugPrint( + '📍 [LocationTracking] Setting initial position (no broadcast)', + ); + + // Update device's advertised location WITHOUT sending advertisement + await _bleService!.setAdvertLatLon( + latitude: position.latitude, + longitude: position.longitude, + ); + + // Mark first position as set + _firstPositionSet = true; + + // Save to preferences + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_scopedKey(_prefKeyLastLat), position.latitude); + await prefs.setDouble(_scopedKey(_prefKeyLastLon), position.longitude); + + debugPrint('✅ [LocationTracking] Initial position set without broadcast'); + debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s'); + } catch (e) { + debugPrint('⚠️ [LocationTracking] Failed to set initial position: $e'); + debugPrint(' Will retry on next GPS update'); + // Don't mark as set on failure, so it will retry on next update + // Don't call onError - this is not critical since it will retry automatically + } + } + + /// Check if position should be broadcast based on distance and time thresholds + /// DISABLED: Automatic broadcasting removed - use advert button for manual broadcasts + void _checkAndBroadcast(Position position) { + // Automatic broadcasting disabled + // Use the manual advert button instead + debugPrint( + ' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)', + ); + } + + void setFastLocationActiveUse(bool isActive) { + if (_isFastLocationActiveUse == isActive) return; + _isFastLocationActiveUse = isActive; + _refreshFastLocationTimer(); + } + + Future setFastLocationUpdatesEnabled(bool enabled) async { + fastLocationUpdatesEnabled = enabled; + if (!enabled) { + _resetFastGpsMotionState(); + } + await saveSettings(); + _refreshFastLocationTimer(); + } + + Future updateFastLocationMovementThreshold(double meters) async { + fastLocationMovementThresholdMeters = meters.clamp( + _minFastLocationMovementThresholdMeters, + 1000.0, + ); + await saveSettings(); + } + + Future updateFastLocationActiveCadenceSeconds(int seconds) async { + fastLocationActiveCadenceSeconds = seconds.clamp( + _minFastLocationActiveCadenceSeconds, + _maxFastLocationActiveCadenceSeconds, + ); + await saveSettings(); + _refreshFastLocationTimer(); + } + + Future updateFastLocationChannelIdx(int? channelIdx) async { + fastLocationChannelIdx = channelIdx; + await saveSettings(); + _refreshFastLocationTimer(); + } + + /// Arms the post-RX send hold-off after hearing a peer's fast-GPS beacon, so + /// the phone yields the channel briefly to avoid collisions + /// (FAST_GPS_CHANNEL_RX_HOLDOFF_MS). Called by the app's incoming-beacon path. + void noteFastGpsBeaconHeard() { + _fastGpsRxHoldoffUntil = DateTime.now().add(_fastLocationRxHoldoff); + } + + /// Clears all fast-GPS motion/anchor state. Mirrors resetFastGpsShareState. + void _resetFastGpsMotionState() { + _lastFastLocationSentAt = null; + _lastFastLocationSentLatE6 = null; + _lastFastLocationSentLonE6 = null; + _fastGpsAnchorLatE6 = null; + _fastGpsAnchorLonE6 = null; + _fastGpsRefValid = false; + _fastGpsIsMoving = false; + _fastGpsMoveStateSince = null; + _fastGpsMoveEvalAt = null; + _fastGpsSpeedKmh = 0.0; + _fastGpsSpeedPrevLatE6 = null; + _fastGpsSpeedPrevLonE6 = null; + _fastGpsSpeedPrevAt = null; + _fastGpsRxHoldoffUntil = null; + } + + void _evaluateFastLocationMovement(Position position) { + if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return; + _updateFastGpsMotionState(position); + _maybeSendFastLocation(); + } + + void _refreshFastLocationTimer() { + _fastLocationTimer?.cancel(); + _fastLocationTimer = null; + if (!isTracking || + !fastLocationUpdatesEnabled || + fastLocationChannelIdx == null) { + return; + } + + // Always tick (even when parked) so the stationary keepalive fires without a + // position-stream event. Tick fast while in active use; otherwise just often + // enough to drive the 9-min keepalive cheaply. + final period = _isFastLocationActiveUse + ? _fastLocationActiveTick + : _fastLocationStationaryInterval; + _fastLocationTimer = Timer.periodic(period, (_) => _maybeSendFastLocation()); + } + + /// Updates the EMA ground speed and the moving/stationary anchor state machine + /// from a fresh fix. Faithful port of MyMesh::updateGpsStatusCache. + void _updateFastGpsMotionState(Position position) { + // Fix-quality gate (firmware: satellitesCount >= FAST_GPS_MIN_SATS). + final accuracy = position.accuracy; + if (accuracy.isFinite && + accuracy > 0 && + accuracy > _fastLocationMaxAccuracyMeters) { + return; + } + + final now = DateTime.now(); + final latE6 = (position.latitude * 1e6).round(); + final lonE6 = (position.longitude * 1e6).round(); + + // Ground speed from successive fixes, EMA-smoothed (0.4 prev / 0.6 inst). + if (_fastGpsSpeedPrevLatE6 == null || _fastGpsSpeedPrevAt == null) { + _fastGpsSpeedPrevLatE6 = latE6; + _fastGpsSpeedPrevLonE6 = lonE6; + _fastGpsSpeedPrevAt = now; + } else { + final distM = _distanceE6( + _fastGpsSpeedPrevLatE6!, + _fastGpsSpeedPrevLonE6!, + latE6, + lonE6, + ); + final dtMs = now.difference(_fastGpsSpeedPrevAt!).inMilliseconds; + if (distM >= _fastLocationSpeedGateMeters && dtMs >= 1000) { + final instKmh = (distM / 1000.0) / (dtMs / 3600000.0); + if (instKmh <= _fastLocationSpeedGlitchMaxKmh) { + _fastGpsSpeedKmh = _fastGpsSpeedKmh * 0.4 + instKmh * 0.6; + } + _fastGpsSpeedPrevLatE6 = latE6; + _fastGpsSpeedPrevLonE6 = lonE6; + _fastGpsSpeedPrevAt = now; + } else if (dtMs >= _fastLocationSpeedRebaseline.inMilliseconds) { + _fastGpsSpeedKmh = 0.0; + _fastGpsSpeedPrevLatE6 = latE6; + _fastGpsSpeedPrevLonE6 = lonE6; + _fastGpsSpeedPrevAt = now; + } + } + + // Moving/stationary state machine (anchor + dwell hysteresis), ~1 Hz. + final evalAt = _fastGpsMoveEvalAt; + if (evalAt != null && now.difference(evalAt) < _fastLocationMoveEval) { + return; + } + _fastGpsMoveEvalAt = now; + + if (!_fastGpsRefValid) { + _fastGpsAnchorLatE6 = latE6; + _fastGpsAnchorLonE6 = lonE6; + _fastGpsRefValid = true; + _fastGpsMoveStateSince = null; + _fastGpsIsMoving = false; + return; + } + + final refDist = _distanceE6( + _fastGpsAnchorLatE6!, + _fastGpsAnchorLonE6!, + latE6, + lonE6, + ); + if (!_fastGpsIsMoving) { + if (refDist > _fastLocationMoveRadiusMeters) { + if (_fastGpsMoveStateSince == null) { + _fastGpsMoveStateSince = now; + } else if (now.difference(_fastGpsMoveStateSince!) >= + _fastLocationMoveDwell) { + _fastGpsIsMoving = true; + _fastGpsAnchorLatE6 = latE6; + _fastGpsAnchorLonE6 = lonE6; + _fastGpsMoveStateSince = null; + } + } else { + _fastGpsMoveStateSince = null; + // Track slow GPS bias so cumulative drift never reaches the radius. + _fastGpsAnchorLatE6 = + _fastGpsAnchorLatE6! + ((latE6 - _fastGpsAnchorLatE6!) ~/ 8); + _fastGpsAnchorLonE6 = + _fastGpsAnchorLonE6! + ((lonE6 - _fastGpsAnchorLonE6!) ~/ 8); + } + } else { + if (refDist > _fastLocationMoveRadiusMeters) { + _fastGpsAnchorLatE6 = latE6; + _fastGpsAnchorLonE6 = lonE6; + _fastGpsMoveStateSince = null; + } else if (_fastGpsMoveStateSince == null) { + _fastGpsMoveStateSince = now; + } else if (now.difference(_fastGpsMoveStateSince!) >= + _fastLocationStopDwell) { + _fastGpsIsMoving = false; + _fastGpsAnchorLatE6 = latE6; + _fastGpsAnchorLonE6 = lonE6; + _fastGpsMoveStateSince = null; + } + } + } + + /// Decides whether to emit a beacon now, mirroring maybeSendFastGpsUpdate: + /// moving → speed-tier cadence once past the movement threshold; parked → + /// flat 9-min keepalive of the stable anchor. Honours the RX hold-off. + void _maybeSendFastLocation() { + if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return; + final position = currentPosition; + if (position == null) return; + // No valid anchor yet means no good fix has landed — don't beacon (the + // firmware likewise bails until it has a usable fix). + if (!_fastGpsRefValid) return; + + final int reportLatE6; + final int reportLonE6; + if (_fastGpsIsMoving) { + reportLatE6 = (position.latitude * 1e6).round(); + reportLonE6 = (position.longitude * 1e6).round(); + } else { + // Parked: report the stable anchor, not the wander. + reportLatE6 = _fastGpsAnchorLatE6!; + reportLonE6 = _fastGpsAnchorLonE6!; + } + + final now = DateTime.now(); + final lastAt = _lastFastLocationSentAt; + bool shouldSend = lastAt == null || _lastFastLocationSentLatE6 == null; + String reason = 'initial'; + if (!shouldSend) { + if (_fastGpsIsMoving) { + final distM = _distanceE6( + _lastFastLocationSentLatE6!, + _lastFastLocationSentLonE6!, + reportLatE6, + reportLonE6, + ); + if (distM > fastLocationMovementThresholdMeters) { + final elapsed = now.difference(lastAt!); + final interval = _movingIntervalForSpeedKmh(_fastGpsSpeedKmh); + shouldSend = elapsed >= interval; + reason = 'movement'; + } + } else if (now.difference(lastAt!) >= _fastLocationStationaryInterval) { + shouldSend = true; + reason = 'stationary'; + } + } + if (!shouldSend) return; + + // Yield the channel briefly after hearing a peer beacon. + final holdoff = _fastGpsRxHoldoffUntil; + if (holdoff != null) { + if (now.isBefore(holdoff)) return; + _fastGpsRxHoldoffUntil = null; + } + + _lastFastLocationSentLatE6 = reportLatE6; + _lastFastLocationSentLonE6 = reportLonE6; + _lastFastLocationSentAt = now; + + final speedKmh = _fastGpsSpeedKmh < 0 + ? 0 + : (_fastGpsSpeedKmh > 255.0 ? 255 : (_fastGpsSpeedKmh + 0.5).floor()); + onFastLocationUpdate?.call( + reportLatE6 / 1e6, + reportLonE6 / 1e6, + speedKmh, + reason, + ); + } + + Duration _movingIntervalForSpeedKmh(double speedKmh) { + final speedMps = speedKmh / 3.6; + if (speedMps < _fastLocationIdleSpeedMaxMetersPerSecond) { + return _fastLocationStationaryInterval; + } + if (speedMps < _fastLocationWalkingSpeedMaxMetersPerSecond) { + return _fastLocationWalkingInterval; + } + if (speedMps < _fastLocationFastSpeedMaxMetersPerSecond) { + return _fastLocationFastInterval; + } + return _fastLocationVeryFastInterval; + } + + double _distanceE6(int latA, int lonA, int latB, int lonB) { + return Geolocator.distanceBetween( + latA / 1e6, + lonA / 1e6, + latB / 1e6, + lonB / 1e6, + ); + } + + // ============================================================================ + // Mesh Network Broadcasting + // ============================================================================ + + /// Manually broadcast current location immediately + /// + /// Useful for "Send Location Now" button functionality. + /// Note: Manual broadcasts bypass automatic throttling and can be sent anytime. + /// However, they still update the last broadcast time to maintain proper spacing + /// for subsequent automatic broadcasts. + Future broadcastLocationNow() async { + if (!_isInitialized || _bleService == null) { + onError?.call('Location tracking service not initialized'); + return false; + } + + if (!_bleService!.isConnected) { + onError?.call('Not connected to mesh device'); + return false; + } + + try { + // Get current position + final position = await getCurrentPosition(); + if (position == null) { + onError?.call('Failed to get current position'); + return false; + } + + debugPrint('📤 [LocationTracking] Manual broadcast requested'); + + // Broadcast regardless of automatic throttling thresholds + await _bleService!.setAdvertLatLon( + latitude: position.latitude, + longitude: position.longitude, + ); + + await _bleService!.sendSelfAdvert(floodMode: true); + + debugPrint('✅ [LocationTracking] Manual broadcast successful'); + debugPrint( + ' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s', + ); + onBroadcastSent?.call(position); + + return true; + } catch (e) { + debugPrint('❌ [LocationTracking] Manual broadcast failed: $e'); + onError?.call('Failed to broadcast location: $e'); + return false; + } + } + + // ============================================================================ + // Settings Persistence + // ============================================================================ + + /// Load settings from SharedPreferences + Future loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + + minDistanceMeters = prefs.getDouble(_scopedKey(_prefKeyMinDistance)) ?? 5.0; + maxDistanceMeters = + prefs.getDouble(_scopedKey(_prefKeyMaxDistance)) ?? 100.0; + minTimeIntervalSeconds = + prefs.getInt(_scopedKey(_prefKeyMinTimeInterval)) ?? 30; + gpsUpdateDistance = + prefs.getDouble(_scopedKey(_prefKeyGpsUpdateDistance)) ?? 10.0; + fastLocationUpdatesEnabled = + prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false; + fastLocationMovementThresholdMeters = + (prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ?? + _defaultFastLocationMovementThresholdMeters) + .clamp(_minFastLocationMovementThresholdMeters, 1000.0); + fastLocationActiveCadenceSeconds = + (prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ?? + _defaultFastLocationActiveCadenceSeconds) + .clamp( + _minFastLocationActiveCadenceSeconds, + _maxFastLocationActiveCadenceSeconds, + ); + fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx)); + + debugPrint('✅ [LocationTracking] Settings loaded'); + debugPrint(' Min distance: ${minDistanceMeters}m'); + debugPrint(' Max distance: ${maxDistanceMeters}m'); + debugPrint(' Min time interval: ${minTimeIntervalSeconds}s'); + debugPrint(' GPS update distance: ${gpsUpdateDistance}m'); + debugPrint(' Fast updates enabled: $fastLocationUpdatesEnabled'); + debugPrint( + ' Fast movement threshold: ${fastLocationMovementThresholdMeters}m', + ); + debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s'); + debugPrint(' Fast channel idx: ${fastLocationChannelIdx ?? "unset"}'); + } + + /// Save settings to SharedPreferences + Future saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + + await prefs.setDouble(_scopedKey(_prefKeyMinDistance), minDistanceMeters); + await prefs.setDouble(_scopedKey(_prefKeyMaxDistance), maxDistanceMeters); + await prefs.setInt( + _scopedKey(_prefKeyMinTimeInterval), + minTimeIntervalSeconds, + ); + await prefs.setDouble( + _scopedKey(_prefKeyGpsUpdateDistance), + gpsUpdateDistance, + ); + await prefs.setBool(_scopedKey(_prefKeyEnabled), isTracking); + await prefs.setBool( + _scopedKey(_prefKeyFastLocationEnabled), + fastLocationUpdatesEnabled, + ); + await prefs.setDouble( + _scopedKey(_prefKeyFastMovementThreshold), + fastLocationMovementThresholdMeters, + ); + await prefs.setInt( + _scopedKey(_prefKeyFastActiveCadence), + fastLocationActiveCadenceSeconds, + ); + final channelKey = _scopedKey(_prefKeyFastChannelIdx); + if (fastLocationChannelIdx == null) { + await prefs.remove(channelKey); + } else { + await prefs.setInt(channelKey, fastLocationChannelIdx!); + } + + debugPrint('✅ [LocationTracking] Settings saved'); + } + + // ============================================================================ + // Cleanup + // ============================================================================ + + /// Dispose resources and cleanup + void dispose() { + debugPrint('🗑️ [LocationTracking] Disposing service'); + _positionSubscription?.cancel(); + _fastLocationTimer?.cancel(); + _positionSubscription = null; + _bleService = null; + _isInitialized = false; + isTracking = false; + } +} diff --git a/lib/services/map_marker_service.dart b/lib/services/map_marker_service.dart new file mode 100644 index 0000000..3e5bafe --- /dev/null +++ b/lib/services/map_marker_service.dart @@ -0,0 +1,533 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:geolocator/geolocator.dart'; +import '../models/contact.dart'; +import '../models/sar_marker.dart'; +import '../widgets/common/contact_avatar.dart'; +import '../widgets/map/location_pointer.dart'; + +/// Centralized service for map marker management. +/// +/// This service handles: +/// - Contact marker generation +/// - SAR marker generation +/// - User location marker +/// - Distance calculations (Haversine formula) +/// - Bearing/azimuth calculations +/// - Marker color assignment +/// - Marker icon selection +/// +/// Uses singleton pattern for consistent behavior across the app. +class MapMarkerService { + // Singleton pattern + static final MapMarkerService _instance = MapMarkerService._internal(); + factory MapMarkerService() => _instance; + MapMarkerService._internal(); + + /// Generate markers for team member contacts. + /// + /// Parameters: + /// - [contacts]: List of contacts with location data + /// - [context]: Build context for theme access + /// - [onTap]: Callback when a marker is tapped + /// - [mapRotation]: Current map rotation in degrees (for counter-rotation) + /// + /// Returns a list of markers positioned at contact locations. + List generateContactMarkers({ + required List contacts, + required BuildContext context, + Function(Contact)? onTap, + double mapRotation = 0, + Position? userPosition, + Map? estimatedLocations, + }) { + return contacts + .map((contact) { + final location = contact.displayLocation ?? + estimatedLocations?[contact.publicKeyHex]; + if (location == null) return null; + + return Marker( + point: location, + width: 80, + height: 100, + rotate: false, // Don't rotate the entire marker with map + child: Transform.rotate( + angle: -mapRotation * pi / 180, + child: GestureDetector( + onTap: onTap != null ? () => onTap(contact) : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Location update time indicator + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: getLocationAgeColor(contact), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + contact.timeSinceLocationUpdate, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + // Marker icon + Container( + decoration: BoxDecoration( + color: Colors.white, + shape: + contact.type == ContactType.channel || + contact.type == ContactType.room + ? BoxShape.rectangle + : BoxShape.circle, + borderRadius: + contact.type == ContactType.channel || + contact.type == ContactType.room + ? BorderRadius.circular(14) + : null, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(2), + child: ContactAvatar(contact: contact, radius: 16), + ), + const SizedBox(height: 2), + // Name label (without emoji) + Container( + constraints: const BoxConstraints(maxWidth: 80), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + contact.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ); + }) + .whereType() + .toList(); + } + + /// Generate markers for SAR events. + /// + /// Parameters: + /// - [sarMarkers]: List of SAR markers to display + /// - [context]: Build context for theme access + /// - [onTap]: Callback when a marker is tapped + /// - [mapRotation]: Current map rotation in degrees (for counter-rotation) + /// + /// Returns a list of markers positioned at SAR event locations. + List generateSarMarkers({ + required List sarMarkers, + required BuildContext context, + Function(SarMarker)? onTap, + double mapRotation = 0, + LatLng Function(LatLng point)? pointTransformer, + }) { + return sarMarkers.map((marker) { + return Marker( + point: pointTransformer?.call(marker.location) ?? marker.location, + width: 90, + height: 100, + rotate: false, // Don't rotate the entire marker with map + child: Transform.rotate( + angle: -mapRotation * pi / 180, + child: GestureDetector( + onTap: onTap != null ? () => onTap(marker) : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Time ago label + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: getSarMarkerColor(marker.type), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + marker.timeAgo, + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + // Marker emoji/icon + Container( + decoration: BoxDecoration( + color: getSarMarkerColor(marker.type), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: Text( + marker.emoji, // Use custom emoji if available + style: const TextStyle(fontSize: 18), + ), + ), + const SizedBox(height: 2), + // Type label + Container( + constraints: const BoxConstraints(maxWidth: 90), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + marker + .displayName, // Uses notes if available, otherwise type.displayName + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ); + }).toList(); + } + + /// Generate user location marker with directional pointer. + /// + /// Parameters: + /// - [position]: Current GPS position + /// - [heading]: Current heading in degrees (0-360, where 0 = North) + /// Pass null or -1 if heading unavailable + /// - [context]: Build context for theme access + /// + /// Returns null if position is unavailable. + Marker? generateUserLocationMarker({ + required Position? position, + double? heading, + required BuildContext context, + }) { + if (position == null) return null; + + return Marker( + point: LatLng(position.latitude, position.longitude), + width: 60, + height: 60, + rotate: false, // Don't rotate with map - we handle rotation internally + child: LocationPointer( + heading: heading, + color: Theme.of(context).colorScheme.primary, + size: 60, + ), + ); + } + + /// Calculate distance between two lat/lon points using Haversine formula. + /// + /// Parameters: + /// - [lat1]: Starting latitude in decimal degrees + /// - [lon1]: Starting longitude in decimal degrees + /// - [lat2]: Ending latitude in decimal degrees + /// - [lon2]: Ending longitude in decimal degrees + /// + /// Returns distance in meters. + double calculateDistance({ + required double lat1, + required double lon1, + required double lat2, + required double lon2, + }) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + + final a = + sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + /// Calculate bearing/azimuth from point 1 to point 2. + /// + /// Parameters: + /// - [lat1]: Starting latitude in decimal degrees + /// - [lon1]: Starting longitude in decimal degrees + /// - [lat2]: Ending latitude in decimal degrees + /// - [lon2]: Ending longitude in decimal degrees + /// + /// Returns bearing in degrees (0-360), where 0 is North, 90 is East. + double calculateBearing({ + required double lat1, + required double lon1, + required double lat2, + required double lon2, + }) { + final dLon = (lon2 - lon1) * pi / 180; + final lat1Rad = lat1 * pi / 180; + final lat2Rad = lat2 * pi / 180; + + final y = sin(dLon) * cos(lat2Rad); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + + final bearing = atan2(y, x) * 180 / pi; + return (bearing + 360) % 360; + } + + /// Convert bearing to cardinal direction. + /// + /// Parameters: + /// - [bearing]: Bearing in degrees (0-360) + /// + /// Returns cardinal direction (N, NE, E, SE, S, SW, W, NW). + String bearingToCardinal(double bearing) { + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + final index = ((bearing + 22.5) / 45).floor() % 8; + return directions[index]; + } + + /// Format distance for display. + /// + /// Parameters: + /// - [meters]: Distance in meters + /// + /// Returns formatted string (e.g., "123m" or "1.2km"). + String formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } + + /// Get color for SAR marker type. + /// + /// Parameters: + /// - [type]: SAR marker type + /// + /// Returns color for marker background. + Color getSarMarkerColor(SarMarkerType type) { + switch (type) { + case SarMarkerType.foundPerson: + return Colors.green; + case SarMarkerType.fire: + return Colors.red; + case SarMarkerType.stagingArea: + return Colors.orange; + case SarMarkerType.object: + return Colors.purple; + case SarMarkerType.unknown: + return Colors.grey; + } + } + + /// Get color for contact marker based on contact type. + /// + /// Parameters: + /// - [contact]: Contact to get color for + /// - [context]: Build context for theme access + /// + /// Returns color for marker background. + Color getContactMarkerColor(Contact contact, BuildContext context) { + switch (contact.type) { + case ContactType.chat: + return Theme.of(context).colorScheme.primary; // Blue for team members + case ContactType.repeater: + return Colors.deepPurple; // Purple for repeaters + case ContactType.room: + return Colors.teal; // Teal for rooms + case ContactType.sensor: + return Colors.green; // Green for sensors + case ContactType.channel: + return Colors.orange; // Orange for channels + case ContactType.none: + return Colors.grey; + } + } + + /// Get icon for contact marker based on contact type. + /// + /// Parameters: + /// - [contact]: Contact to get icon for + /// + /// Returns icon data for marker. + IconData getContactMarkerIcon(Contact contact) { + switch (contact.type) { + case ContactType.chat: + return Icons.person; // Person for team members + case ContactType.repeater: + return Icons.router; // Router icon for repeaters + case ContactType.room: + return Icons.forum; // Forum/chat icon for rooms + case ContactType.sensor: + return Icons.sensors; // Sensors icon for sensor nodes + case ContactType.channel: + return Icons.public; // Public icon for channels + case ContactType.none: + return Icons.help_outline; + } + } + + /// Get color for location age indicator. + /// + /// Color indicates how recent the location update is: + /// - Green: < 5 minutes (very recent) + /// - Light blue: 5-30 minutes (recent) + /// - Orange: 30 minutes - 2 hours (getting old) + /// - Red: > 2 hours (stale) + /// - Grey: Unknown + /// + /// Parameters: + /// - [contact]: Contact to check location age for + /// + /// Returns color for location age indicator. + Color getLocationAgeColor(Contact contact) { + final updateTime = contact.locationUpdateTime; + if (updateTime == null) return Colors.grey; + + final diff = DateTime.now().difference(updateTime); + if (diff.inMinutes < 5) return Colors.green; // Very recent + if (diff.inMinutes < 30) return Colors.lightBlue; // Recent + if (diff.inHours < 2) return Colors.orange; // Getting old + return Colors.red; // Stale + } + + /// Cluster markers if too many are visible. + /// + /// This is a placeholder for future clustering implementation. + /// When implemented, it should group nearby markers into clusters + /// to improve performance and reduce visual clutter. + /// + /// Parameters: + /// - [markers]: All markers to potentially cluster + /// - [maxVisibleMarkers]: Maximum number of individual markers to show + /// + /// Returns list of markers (clustered or original). + List clusterMarkers({ + required List markers, + required int maxVisibleMarkers, + }) { + // TODO: Implement marker clustering algorithm + // For now, just return all markers + return markers; + } + + /// Calculate optimal map center from list of points. + /// + /// Parameters: + /// - [contacts]: Contacts with locations + /// - [sarMarkers]: SAR markers with locations + /// - [defaultCenter]: Fallback center if no points available + /// + /// Returns center point (average of all locations). + LatLng calculateCenter({ + required List contacts, + required List sarMarkers, + LatLng? defaultCenter, + }) { + final allPoints = []; + + for (final contact in contacts) { + if (contact.displayLocation != null) { + allPoints.add(contact.displayLocation!); + } + } + + for (final marker in sarMarkers) { + allPoints.add(marker.location); + } + + if (allPoints.isEmpty) { + return defaultCenter ?? + const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia + } + + double lat = 0, lng = 0; + for (final point in allPoints) { + lat += point.latitude; + lng += point.longitude; + } + + return LatLng(lat / allPoints.length, lng / allPoints.length); + } + + /// Check if two positions are close enough to be considered the same location. + /// + /// Parameters: + /// - [lat1]: First latitude + /// - [lon1]: First longitude + /// - [lat2]: Second latitude + /// - [lon2]: Second longitude + /// - [thresholdMeters]: Distance threshold in meters (default: 50) + /// + /// Returns true if points are within threshold distance. + bool isNearby({ + required double lat1, + required double lon1, + required double lat2, + required double lon2, + double thresholdMeters = 50, + }) { + final distance = calculateDistance( + lat1: lat1, + lon1: lon1, + lat2: lat2, + lon2: lon2, + ); + return distance <= thresholdMeters; + } +} diff --git a/lib/services/map_workspace_snapshot_service.dart b/lib/services/map_workspace_snapshot_service.dart new file mode 100644 index 0000000..8cf5725 --- /dev/null +++ b/lib/services/map_workspace_snapshot_service.dart @@ -0,0 +1,99 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/config_profile.dart'; +import '../providers/drawing_provider.dart'; +import '../providers/map_provider.dart'; +import 'profiles_feature_service.dart'; + +class MapWorkspaceSnapshotService { + Future capture({ + required MapProvider mapProvider, + required DrawingProvider drawingProvider, + }) async { + final prefs = await SharedPreferences.getInstance(); + return MapWorkspaceProfileSection( + mapPrefs: { + 'map_rotate_with_heading': + prefs.getBool( + ProfileStorageScope.scopedKey('map_rotate_with_heading'), + ) ?? + false, + 'map_show_debug_info': + prefs.getBool( + ProfileStorageScope.scopedKey('map_show_debug_info'), + ) ?? + false, + 'map_fullscreen': + prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ?? + false, + 'map_last_latitude': prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_latitude'), + ), + 'map_last_longitude': prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_longitude'), + ), + 'map_last_zoom': prefs.getDouble( + ProfileStorageScope.scopedKey('map_last_zoom'), + ), + 'map_last_layer_type': prefs.getInt( + ProfileStorageScope.scopedKey('map_last_layer_type'), + ), + 'map_gps_update_distance': prefs.getDouble( + ProfileStorageScope.scopedKey('map_gps_update_distance'), + ), + 'background_tracking_enabled': prefs.getBool( + ProfileStorageScope.scopedKey('background_tracking_enabled'), + ), + }, + drawings: drawingProvider.exportDrawingsJson(), + currentTrail: mapProvider.currentTrail?.toJson(), + trailHistory: mapProvider.trailHistory + .map((trail) => trail.toJson()) + .toList(), + importedTrail: mapProvider.importedTrail?.toJson(), + isTrailVisible: mapProvider.isTrailVisible, + showCadastralOverlay: mapProvider.showCadastralOverlay, + showForestRoadsOverlay: mapProvider.showForestRoadsOverlay, + showHikingTrailsOverlay: mapProvider.showHikingTrailsOverlay, + showMainRoadsOverlay: mapProvider.showMainRoadsOverlay, + showHouseNumbersOverlay: mapProvider.showHouseNumbersOverlay, + showFireHazardZonesOverlay: mapProvider.showFireHazardZonesOverlay, + showHistoricalFiresOverlay: mapProvider.showHistoricalFiresOverlay, + showFirebreaksOverlay: mapProvider.showFirebreaksOverlay, + showKrasFireZonesOverlay: mapProvider.showKrasFireZonesOverlay, + showPlaceNamesOverlay: mapProvider.showPlaceNamesOverlay, + showMunicipalityBordersOverlay: + mapProvider.showMunicipalityBordersOverlay, + hideRepeatersOnMap: mapProvider.hideRepeatersOnMap, + ); + } + + Future apply( + MapWorkspaceProfileSection? section, { + required MapProvider mapProvider, + required DrawingProvider drawingProvider, + }) async { + if (section == null || section.isEmpty) { + return; + } + + final prefs = await SharedPreferences.getInstance(); + final mapPrefs = section.mapPrefs ?? const {}; + for (final entry in mapPrefs.entries) { + final key = ProfileStorageScope.scopedKey(entry.key); + final value = entry.value; + if (value == null) { + await prefs.remove(key); + } else if (value is bool) { + await prefs.setBool(key, value); + } else if (value is int) { + await prefs.setInt(key, value); + } else if (value is double) { + await prefs.setDouble(key, value); + } + } + + mapProvider.applyWorkspaceJson(section.toJson()); + await drawingProvider.replaceDrawingsFromJson(section.drawings); + } +} diff --git a/lib/services/mesh_map_nodes_service.dart b/lib/services/mesh_map_nodes_service.dart new file mode 100644 index 0000000..ffc027d --- /dev/null +++ b/lib/services/mesh_map_nodes_service.dart @@ -0,0 +1,217 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +class MeshMapNode { + final int type; + final String name; + final String publicKey; + final double latitude; + final double longitude; + final int updatedAtMs; + + const MeshMapNode({ + required this.type, + required this.name, + required this.publicKey, + required this.latitude, + required this.longitude, + required this.updatedAtMs, + }); + + bool get hasValidCoordinates => + latitude >= -90 && + latitude <= 90 && + longitude >= -180 && + longitude <= 180 && + latitude != 0 && + longitude != 0; + + factory MeshMapNode.fromJson(Map json) { + return MeshMapNode( + type: (json['type'] as num?)?.toInt() ?? 0, + name: (json['name'] as String?)?.trim() ?? 'Unknown', + publicKey: ((json['public_key'] as String?) ?? '').toLowerCase(), + latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, + updatedAtMs: (json['updated_at'] as num?)?.toInt() ?? 0, + ); + } +} + +class MeshMapNodesService { + static const String _nodesEndpoint = + 'https://api.meshcore.nz/api/v1/map/nodes'; + static const int repeaterType = 1; + static const Duration _cacheTtl = Duration(hours: 24); + 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? _cachedNodes; + static DateTime? _cachedAt; + static Future? _ongoingSync; + + static Future> fetchNodes({ + bool forceRefresh = false, + Duration cacheTtl = _cacheTtl, + bool allowNetwork = true, + http.Client? client, + }) async { + final now = DateTime.now(); + final cached = await loadCachedNodes(cacheTtl: cacheTtl); + if (!forceRefresh && + cached.isNotEmpty && + _cachedAt != null && + now.difference(_cachedAt!) < cacheTtl) { + return cached; + } + + if (!allowNetwork) { + return cached; + } + + final response = await (client ?? http.Client()) + .get(Uri.parse(_nodesEndpoint)) + .timeout(_requestTimeout); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('Map nodes API returned ${response.statusCode}'); + } + + final decoded = jsonDecode(response.body) as Map; + final nodesRaw = decoded['nodes'] as List? ?? const []; + + final nodes = nodesRaw + .whereType>() + .map(MeshMapNode.fromJson) + .where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates) + .toList(); + + await _storeCache(nodes, cachedAt: now); + return nodes; + } + + static Future> loadCachedNodes({ + Duration cacheTtl = _cacheTtl, + }) async { + final now = DateTime.now(); + if (_cachedNodes != null && + _cachedAt != null && + now.difference(_cachedAt!) < cacheTtl) { + return _cachedNodes!; + } + + final prefs = await SharedPreferences.getInstance(); + final cachedAtMs = prefs.getInt(_cacheTimestampKey); + final cachedJson = prefs.getString(_cacheKey); + if (cachedAtMs == null || cachedJson == null) { + _cachedNodes = null; + _cachedAt = null; + return const []; + } + + final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs); + if (now.difference(cachedAt) >= cacheTtl) { + _cachedNodes = null; + _cachedAt = cachedAt; + return const []; + } + + final decoded = jsonDecode(cachedJson) as List; + final nodes = decoded + .whereType>() + .map(MeshMapNode.fromJson) + .where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates) + .toList(); + _cachedNodes = nodes; + _cachedAt = cachedAt; + return nodes; + } + + static Future hasFreshCache({Duration cacheTtl = _cacheTtl}) async { + final nodes = await loadCachedNodes(cacheTtl: cacheTtl); + return nodes.isNotEmpty; + } + + static Future syncInBackgroundIfStale({ + Duration cacheTtl = _cacheTtl, + http.Client? client, + }) async { + final now = DateTime.now(); + if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) { + return; + } + + final cached = await loadCachedNodes(cacheTtl: cacheTtl); + if (cached.isNotEmpty && _cachedAt != null) { + return; + } + + if (_ongoingSync != null) { + return _ongoingSync!; + } + + _ongoingSync = () async { + try { + await fetchNodes( + forceRefresh: true, + cacheTtl: cacheTtl, + allowNetwork: true, + client: client, + ); + } catch (_) { + // Background refresh is best-effort only. + } finally { + _ongoingSync = null; + } + }(); + + return _ongoingSync!; + } + + static Future clearCache() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_cacheKey); + await prefs.remove(_cacheTimestampKey); + _cachedNodes = null; + _cachedAt = null; + } + + static Future cachedAt() async { + if (_cachedAt != null) return _cachedAt; + final prefs = await SharedPreferences.getInstance(); + final cachedAtMs = prefs.getInt(_cacheTimestampKey); + if (cachedAtMs == null) return null; + _cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs); + return _cachedAt; + } + + static bool isRepeater(MeshMapNode node) => node.type == repeaterType; + + static Future _storeCache( + List nodes, { + required DateTime cachedAt, + }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _cacheKey, + jsonEncode( + nodes + .map( + (node) => { + 'type': node.type, + 'name': node.name, + 'public_key': node.publicKey, + 'latitude': node.latitude, + 'longitude': node.longitude, + 'updated_at': node.updatedAtMs, + }, + ) + .toList(), + ), + ); + await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch); + _cachedNodes = nodes; + _cachedAt = cachedAt; + } +} diff --git a/lib/services/message_destination_preferences.dart b/lib/services/message_destination_preferences.dart new file mode 100644 index 0000000..afb4018 --- /dev/null +++ b/lib/services/message_destination_preferences.dart @@ -0,0 +1,128 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Service for managing message destination preferences +/// Stores the last selected recipient (channel, contact, or room) for sending messages +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'; + static const String destinationTypeChannel = 'channel'; + 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) + static Future?> getDestination() async { + final prefs = await SharedPreferences.getInstance(); + final type = prefs.getString(_destinationTypeKey); + + if (type == null) { + return null; // Use default (public channel) + } + + final publicKey = prefs.getString(_recipientPublicKeyKey); + + return {'type': type, 'publicKey': ?publicKey}; + } + + /// Save the selected destination + /// [type] - one of: destinationTypeAll, destinationTypeChannel, destinationTypeContact, destinationTypeRoom + /// [recipientPublicKey] - hex string of recipient's public key (required for contact/room) + static Future setDestination( + String type, { + String? recipientPublicKey, + }) async { + final prefs = await SharedPreferences.getInstance(); + + await prefs.setString(_destinationTypeKey, type); + + if (recipientPublicKey != null) { + await prefs.setString(_recipientPublicKeyKey, recipientPublicKey); + } else { + await prefs.remove(_recipientPublicKeyKey); + } + } + + /// Clear the saved destination (resets to default public channel) + static Future clearDestination() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_destinationTypeKey); + await prefs.remove(_recipientPublicKeyKey); + } + + /// Get the saved locked destination configuration. + /// Returns null when the lock is disabled. + static Future?> 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 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) { + case destinationTypeAll: + return 'All'; + case destinationTypeChannel: + return 'Channel'; + case destinationTypeContact: + return 'Contact'; + case destinationTypeRoom: + return 'Room'; + default: + return 'Unknown'; + } + } +} diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart new file mode 100644 index 0000000..12b362a --- /dev/null +++ b/lib/services/message_storage_service.dart @@ -0,0 +1,667 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/message.dart'; +import '../models/message_contact_location.dart'; +import '../models/message_reception_details.dart'; +import '../models/message_transfer_details.dart'; +import '../models/message_route_metadata.dart'; +import 'profiles_feature_service.dart'; +import 'package:latlong2/latlong.dart'; + +/// Service for persisting messages to local storage +class MessageStorageService { + static const String _messagesKey = 'stored_messages'; + static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids'; + static const String _messageContactLocationsKey = + 'stored_message_contact_locations'; + static const String _messageReceptionDetailsKey = + 'stored_message_reception_details'; + static const String _messageTransferDetailsKey = + 'stored_message_transfer_details'; + static const String _messageRouteMetadataKey = + 'stored_message_route_metadata'; + static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails'; + static const String _legacyPathBytesKey = 'storedPathBytes'; + static const int _maxStoredMessages = 1000; // Store up to 1000 messages + + String _key(String baseKey, {String? namespace}) { + return ProfileStorageScope.scopedKey(baseKey, namespace: namespace); + } + + /// Save messages to persistent storage + Future saveMessages( + List messages, { + Map messageContactLocations = const {}, + Map messageReceptionDetails = const {}, + Map messageTransferDetails = const {}, + Map messageRouteMetadata = const {}, + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + + // Convert messages to JSON and embed path bytes as a fallback so they + // survive restore even if the sidecar reception-details entry is absent. + final jsonList = messages + .map( + (msg) => _messageToJson( + msg, + receptionDetails: messageReceptionDetails[msg.id], + ), + ) + .toList(); + + // Limit to max stored messages (keep most recent) + final limitedList = jsonList.length > _maxStoredMessages + ? jsonList.sublist(jsonList.length - _maxStoredMessages) + : jsonList; + + final jsonString = jsonEncode(limitedList); + final messagesKey = _key(_messagesKey, namespace: namespace); + final retainedMessageIds = limitedList + .map((entry) => entry['id'] as String) + .toSet(); + final locationJson = {}; + final receptionJson = {}; + final transferJson = {}; + final routeMetadataJson = {}; + for (final entry in messageContactLocations.entries) { + if (retainedMessageIds.contains(entry.key)) { + locationJson[entry.key] = entry.value.toJson(); + } + } + for (final entry in messageReceptionDetails.entries) { + if (retainedMessageIds.contains(entry.key)) { + receptionJson[entry.key] = entry.value.toJson(); + } + } + for (final entry in messageTransferDetails.entries) { + if (retainedMessageIds.contains(entry.key)) { + transferJson[entry.key] = entry.value.toJson(); + } + } + for (final entry in messageRouteMetadata.entries) { + if (retainedMessageIds.contains(entry.key)) { + routeMetadataJson[entry.key] = entry.value.toJson(); + } + } + final contactLocationsKey = _key( + _messageContactLocationsKey, + namespace: namespace, + ); + final receptionDetailsKey = _key( + _messageReceptionDetailsKey, + namespace: namespace, + ); + final transferDetailsKey = _key( + _messageTransferDetailsKey, + namespace: namespace, + ); + 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', + ); + } catch (e) { + debugPrint('❌ [MessageStorage] Error saving messages: $e'); + } + } + + Future> loadMessageContactLocations({ + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_messageContactLocationsKey, namespace: namespace), + ); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! Map) { + return const {}; + } + + final result = {}; + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) continue; + final snapshot = MessageContactLocation.fromJson(value); + if (snapshot != null) { + result[entry.key] = snapshot; + } + } + return result; + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading contact locations: $e'); + return const {}; + } + } + + Future> loadMessageReceptionDetails({ + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_messageReceptionDetailsKey, namespace: namespace), + ); + final result = {}; + if (jsonString != null && jsonString.isNotEmpty) { + final decoded = jsonDecode(jsonString); + if (decoded is Map) { + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) continue; + final snapshot = MessageReceptionDetails.fromJson(value); + if (snapshot != null) { + result[entry.key] = snapshot; + } + } + } + } + + final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails( + namespace: namespace, + ); + embeddedReceptionDetails.forEach((messageId, snapshot) { + result.putIfAbsent(messageId, () => snapshot); + }); + + final fallbackPathBytes = await _loadLegacyPathBytesFromMessages( + namespace: namespace, + ); + fallbackPathBytes.forEach((messageId, pathBytes) { + result.putIfAbsent( + messageId, + () => MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(0), + pathBytes: pathBytes, + ), + ); + }); + return result; + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading reception details: $e'); + return const {}; + } + } + + Future> loadMessageTransferDetails({ + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_messageTransferDetailsKey, namespace: namespace), + ); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! Map) { + return const {}; + } + + final result = {}; + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) continue; + final details = MessageTransferDetails.fromJson(value); + if (details != null) { + result[entry.key] = details; + } + } + return result; + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading transfer details: $e'); + return const {}; + } + } + + Future> loadMessageRouteMetadata({ + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_messageRouteMetadataKey, namespace: namespace), + ); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! Map) { + return const {}; + } + + final result = {}; + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) continue; + result[entry.key] = MessageRouteMetadata.fromJson(value); + } + return result; + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading route metadata: $e'); + return const {}; + } + } + + /// Load messages from persistent storage + Future> loadMessages({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_messagesKey, namespace: namespace), + ); + + if (jsonString == null || jsonString.isEmpty) { + debugPrint('ℹ️ [MessageStorage] No stored messages found'); + return []; + } + + final jsonList = jsonDecode(jsonString) as List; + final messages = jsonList + .map((json) => _messageFromJson(json as Map)) + .where((msg) => msg != null) + .cast() + .toList(); + + debugPrint( + '✅ [MessageStorage] Loaded ${messages.length} messages from storage', + ); + return messages; + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading messages: $e'); + return []; + } + } + + /// Clear all stored messages + Future clearMessages({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key(_messagesKey, namespace: namespace)); + await prefs.remove( + _key(_messageContactLocationsKey, namespace: namespace), + ); + await prefs.remove( + _key(_messageReceptionDetailsKey, namespace: namespace), + ); + await prefs.remove( + _key(_messageTransferDetailsKey, namespace: namespace), + ); + await prefs.remove(_key(_messageRouteMetadataKey, namespace: namespace)); + await prefs.remove(_key(_removedSarMarkerIdsKey, namespace: namespace)); + debugPrint('✅ [MessageStorage] Cleared all stored messages'); + } catch (e) { + debugPrint('❌ [MessageStorage] Error clearing messages: $e'); + } + } + + Future> loadRemovedSarMarkerIds({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final ids = + prefs.getStringList( + _key(_removedSarMarkerIdsKey, namespace: namespace), + ) ?? + const []; + return ids.toSet(); + } catch (e) { + debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e'); + return const {}; + } + } + + Future saveRemovedSarMarkerIds( + Set ids, { + String? namespace, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _key(_removedSarMarkerIdsKey, namespace: namespace), + ids.toList()..sort(), + ); + debugPrint( + '✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs', + ); + } catch (e) { + debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e'); + } + } + + /// Get storage statistics + Future> getStorageStats({String? namespace}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_messagesKey, namespace: namespace), + ); + + if (jsonString == null || jsonString.isEmpty) { + return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; + } + + final sizeBytes = jsonString.length; + final jsonList = jsonDecode(jsonString) as List; + + return { + 'messageCount': jsonList.length, + 'storageSizeBytes': sizeBytes, + 'storageSizeKB': (sizeBytes / 1024).toStringAsFixed(2), + }; + } catch (e) { + debugPrint('❌ [MessageStorage] Error getting storage stats: $e'); + return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; + } + } + + /// Convert Message to JSON + Map _messageToJson( + Message message, { + MessageReceptionDetails? receptionDetails, + }) { + final persistedPathBytes = + message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails); + return { + 'id': message.id, + 'messageType': message.messageType.name, + 'senderPublicKeyPrefix': message.senderPublicKeyPrefix != null + ? base64Encode(message.senderPublicKeyPrefix!) + : null, + 'channelIdx': message.channelIdx, + 'pathLen': message.pathLen, + 'pathBytes': persistedPathBytes?.toList(), + 'textType': message.textType.value, + 'senderTimestamp': message.senderTimestamp, + 'text': message.text, + 'isSarMarker': message.isSarMarker, + 'sarGpsLat': message.sarGpsCoordinates?.latitude, + 'sarGpsLon': message.sarGpsCoordinates?.longitude, + 'sarCustomMapLat': message.sarCustomMapPoint?.latitude, + 'sarCustomMapLon': message.sarCustomMapPoint?.longitude, + 'sarCustomMapId': message.sarCustomMapId, + 'sarNotes': message.sarNotes, + 'sarCustomEmoji': message.sarCustomEmoji, + 'sarColorIndex': message.sarColorIndex, + 'receivedAtMillis': message.receivedAt.millisecondsSinceEpoch, + 'senderName': message.senderName, + 'deliveryStatus': message.deliveryStatus.name, + 'expectedAckTag': message.expectedAckTag, + 'suggestedTimeoutMs': message.suggestedTimeoutMs, + 'roundTripTimeMs': message.roundTripTimeMs, + 'deliveredAtMillis': message.deliveredAt?.millisecondsSinceEpoch, + 'recipientPublicKey': message.recipientPublicKey != null + ? base64Encode(message.recipientPublicKey!) + : null, + 'isRead': message.isRead, + // Retry state tracking (IMPORTANT for preserving state across app restarts) + 'retryAttempt': message.retryAttempt, + 'lastRetryAtMillis': message.lastRetryAt?.millisecondsSinceEpoch, + 'usedFloodFallback': message.usedFloodFallback, + // Echo detection for channel messages + 'echoCount': message.echoCount, + 'firstEchoAtMillis': message.firstEchoAt?.millisecondsSinceEpoch, + 'lastEchoSnrRaw': message.lastEchoSnrRaw, + 'lastEchoRssiDbm': message.lastEchoRssiDbm, + 'lastEchoAtMillis': message.lastEchoAt?.millisecondsSinceEpoch, + // Drawing message tracking + 'isDrawing': message.isDrawing, + 'drawingId': message.drawingId, + // Voice message tracking + 'isVoice': message.isVoice, + 'voiceId': message.voiceId, + // Message grouping (for bulk sends) + 'groupId': message.groupId, + 'recipients': message.recipients + ?.map( + (r) => { + 'publicKey': base64Encode(r.publicKey), + 'displayName': r.displayName, + 'deliveryStatus': r.deliveryStatus.name, + 'expectedAckTag': r.expectedAckTag, + 'roundTripTimeMs': r.roundTripTimeMs, + 'deliveredAtMillis': r.deliveredAt?.millisecondsSinceEpoch, + 'sentAtMillis': r.sentAt.millisecondsSinceEpoch, + }, + ) + .toList(), + if (receptionDetails != null) + _embeddedReceptionDetailsKey: receptionDetails.toJson(), + if (receptionDetails?.pathBytes case final pathBytes?) + _legacyPathBytesKey: List.from(pathBytes), + }; + } + + Future> _loadEmbeddedReceptionDetails({ + String? namespace, + }) async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_messagesKey, namespace: namespace), + ); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! List) { + return const {}; + } + + final result = {}; + for (final entry in decoded) { + if (entry is! Map) continue; + final messageId = entry['id']; + final embedded = entry[_embeddedReceptionDetailsKey]; + if (messageId is! String || embedded is! Map) continue; + final snapshot = MessageReceptionDetails.fromJson(embedded); + if (snapshot == null) continue; + result[messageId] = snapshot; + } + return result; + } + + Future>> _loadLegacyPathBytesFromMessages({ + String? namespace, + }) async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString( + _key(_messagesKey, namespace: namespace), + ); + if (jsonString == null || jsonString.isEmpty) { + return const {}; + } + + final decoded = jsonDecode(jsonString); + if (decoded is! List) { + return const {}; + } + + final result = >{}; + for (final entry in decoded) { + if (entry is! Map) continue; + final messageId = entry['id']; + final pathBytes = entry[_legacyPathBytesKey]; + if (messageId is! String || pathBytes is! List) continue; + final normalized = pathBytes + .whereType() + .map((b) => b.toInt()) + .toList(); + if (normalized.isEmpty) continue; + result[messageId] = normalized; + } + return result; + } + + /// Convert JSON to Message + Message? _messageFromJson(Map json) { + try { + return Message( + id: json['id'] as String, + messageType: MessageType.values.firstWhere( + (e) => e.name == json['messageType'], + orElse: () => MessageType.contact, + ), + senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null + ? Uint8List.fromList( + base64Decode(json['senderPublicKeyPrefix'] as String), + ) + : null, + channelIdx: json['channelIdx'] as int?, + pathLen: json['pathLen'] as int, + pathBytes: json['pathBytes'] is List + ? Uint8List.fromList( + (json['pathBytes'] as List) + .whereType() + .map((b) => b.toInt()) + .toList(), + ) + : null, + textType: MessageTextType.fromValue(json['textType'] as int), + senderTimestamp: json['senderTimestamp'] as int, + text: json['text'] as String, + isSarMarker: json['isSarMarker'] as bool? ?? false, + sarGpsCoordinates: + json['sarGpsLat'] != null && json['sarGpsLon'] != null + ? LatLng( + (json['sarGpsLat'] as num).toDouble(), + (json['sarGpsLon'] as num).toDouble(), + ) + : null, + sarCustomMapPoint: + json['sarCustomMapLat'] != null && json['sarCustomMapLon'] != null + ? LatLng( + (json['sarCustomMapLat'] as num).toDouble(), + (json['sarCustomMapLon'] as num).toDouble(), + ) + : null, + sarCustomMapId: json['sarCustomMapId'] as String?, + sarNotes: json['sarNotes'] as String?, + sarCustomEmoji: json['sarCustomEmoji'] as String?, + sarColorIndex: json['sarColorIndex'] as int?, + receivedAt: DateTime.fromMillisecondsSinceEpoch( + json['receivedAtMillis'] as int, + ), + senderName: json['senderName'] as String?, + deliveryStatus: json['deliveryStatus'] != null + ? MessageDeliveryStatus.values.firstWhere( + (e) => e.name == json['deliveryStatus'], + orElse: () => MessageDeliveryStatus.received, + ) + : MessageDeliveryStatus.received, + expectedAckTag: json['expectedAckTag'] as int?, + suggestedTimeoutMs: json['suggestedTimeoutMs'] as int?, + roundTripTimeMs: json['roundTripTimeMs'] as int?, + deliveredAt: json['deliveredAtMillis'] != null + ? DateTime.fromMillisecondsSinceEpoch( + json['deliveredAtMillis'] as int, + ) + : null, + recipientPublicKey: json['recipientPublicKey'] != null + ? Uint8List.fromList( + base64Decode(json['recipientPublicKey'] as String), + ) + : null, + isRead: json['isRead'] as bool? ?? false, + // Retry state tracking (preserves retry/flood state across restarts) + retryAttempt: json['retryAttempt'] as int? ?? 0, + lastRetryAt: json['lastRetryAtMillis'] != null + ? DateTime.fromMillisecondsSinceEpoch( + json['lastRetryAtMillis'] as int, + ) + : null, + usedFloodFallback: json['usedFloodFallback'] as bool? ?? false, + // Echo detection + echoCount: json['echoCount'] as int? ?? 0, + firstEchoAt: json['firstEchoAtMillis'] != null + ? DateTime.fromMillisecondsSinceEpoch( + json['firstEchoAtMillis'] as int, + ) + : null, + lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?, + lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?, + lastEchoAt: json['lastEchoAtMillis'] != null + ? DateTime.fromMillisecondsSinceEpoch( + json['lastEchoAtMillis'] as int, + ) + : null, + // Drawing message tracking + isDrawing: json['isDrawing'] as bool? ?? false, + drawingId: json['drawingId'] as String?, + // Voice message tracking + isVoice: json['isVoice'] as bool? ?? false, + voiceId: json['voiceId'] as String?, + // Message grouping + groupId: json['groupId'] as String?, + recipients: json['recipients'] != null + ? (json['recipients'] as List) + .map( + (r) => MessageRecipient( + publicKey: Uint8List.fromList( + base64Decode(r['publicKey'] as String), + ), + displayName: r['displayName'] as String, + deliveryStatus: MessageDeliveryStatus.values.firstWhere( + (e) => e.name == r['deliveryStatus'], + orElse: () => MessageDeliveryStatus.sending, + ), + expectedAckTag: r['expectedAckTag'] as int?, + roundTripTimeMs: r['roundTripTimeMs'] as int?, + deliveredAt: r['deliveredAtMillis'] != null + ? DateTime.fromMillisecondsSinceEpoch( + r['deliveredAtMillis'] as int, + ) + : null, + sentAt: DateTime.fromMillisecondsSinceEpoch( + r['sentAtMillis'] as int, + ), + ), + ) + .toList() + : null, + ); + } catch (e) { + debugPrint('❌ [MessageStorage] Error parsing message from JSON: $e'); + return null; + } + } + + Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) { + final pathBytes = receptionDetails?.pathBytes; + if (pathBytes == null || pathBytes.isEmpty) { + return null; + } + return Uint8List.fromList(pathBytes); + } +} diff --git a/lib/services/messaging_route_preferences.dart b/lib/services/messaging_route_preferences.dart new file mode 100644 index 0000000..277b54d --- /dev/null +++ b/lib/services/messaging_route_preferences.dart @@ -0,0 +1,53 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'profiles_feature_service.dart'; + +class MessagingRoutePreferences { + static const bool defaultClearPathOnMaxRetry = false; + static const bool defaultNearestRelayFallbackEnabled = true; + + 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 cleanupLegacySettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove( + ProfileStorageScope.scopedKey(_legacyAutoRouteRotationKey), + ); + } + + static Future getClearPathOnMaxRetry() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool( + ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey), + ) ?? + defaultClearPathOnMaxRetry; + } + + static Future setClearPathOnMaxRetry(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey), + enabled, + ); + } + + static Future getNearestRelayFallbackEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool( + ProfileStorageScope.scopedKey(_nearestRelayFallbackKey), + ) ?? + defaultNearestRelayFallbackEnabled; + } + + static Future setNearestRelayFallbackEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool( + ProfileStorageScope.scopedKey(_nearestRelayFallbackKey), + enabled, + ); + } +} diff --git a/lib/services/nearest_router_selector.dart b/lib/services/nearest_router_selector.dart new file mode 100644 index 0000000..edba1f6 --- /dev/null +++ b/lib/services/nearest_router_selector.dart @@ -0,0 +1,69 @@ +import 'package:geolocator/geolocator.dart'; + +import '../models/contact.dart'; + +class NearestRouterSelector { + const NearestRouterSelector(); + + Contact? select({ + required Position? senderPosition, + required List repeaters, + required Contact recipient, + }) { + if (senderPosition == null) { + return null; + } + + final eligible = repeaters.where((contact) { + if (contact.publicKeyHex == recipient.publicKeyHex) { + return false; + } + if (!contact.isRecentlySeen) { + return false; + } + return contact.displayLocation != null; + }).toList(); + if (eligible.isEmpty) { + return null; + } + + eligible.sort((a, b) { + final locationA = a.displayLocation!; + final locationB = b.displayLocation!; + final distanceA = Geolocator.distanceBetween( + senderPosition.latitude, + senderPosition.longitude, + locationA.latitude, + locationA.longitude, + ); + final distanceB = Geolocator.distanceBetween( + senderPosition.latitude, + senderPosition.longitude, + locationB.latitude, + locationB.longitude, + ); + final distanceCompare = distanceA.compareTo(distanceB); + if (distanceCompare != 0) { + return distanceCompare; + } + + final advertCompare = b.lastAdvert.compareTo(a.lastAdvert); + if (advertCompare != 0) { + return advertCompare; + } + + final hopCompare = a.routeHopCount.compareTo(b.routeHopCount); + if (hopCompare != 0) { + return hopCompare; + } + + final nameCompare = a.advName.compareTo(b.advName); + if (nameCompare != 0) { + return nameCompare; + } + return a.publicKeyHex.compareTo(b.publicKeyHex); + }); + + return eligible.first; + } +} diff --git a/lib/services/network_scanner_service.dart b/lib/services/network_scanner_service.dart new file mode 100644 index 0000000..a6bebce --- /dev/null +++ b/lib/services/network_scanner_service.dart @@ -0,0 +1,282 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +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($displayName @ $ipAddress:$port, ${responseTime}ms)'; + + @override + bool operator ==(Object other) => + other is DiscoveredServer && + other.ipAddress == ipAddress && + other.port == port; + + @override + int get hashCode => Object.hash(ipAddress, port); +} + +/// Discovers MeshCore devices running the TCP/WiFi server (port 5000). +/// +/// First tries mDNS/Bonjour (_meshcore._tcp), then falls back to a parallel +/// TCP-connect port scan of the local /24 subnet. +class NetworkScannerService { + static const int defaultPort = 5000; + static const String serviceType = '_meshcore._tcp'; + static const int parallelScans = 20; + static const Duration connectTimeout = Duration(seconds: 2); + static const Duration bonjourTimeout = Duration(seconds: 5); + + Discovery? _activeDiscovery; + + Function(DiscoveredServer)? onServerDiscovered; + Function(int scanned, int total)? onProgressUpdate; + + bool _isScanning = false; + bool get isScanning => _isScanning; + int _scanSession = 0; + + List _cachedServers = []; + List get cachedServers => List.unmodifiable(_cachedServers); + bool get hasCachedResults => _cachedServers.isNotEmpty; + + // ── Helpers ──────────────────────────────────────────────────────────────── + + Future> _getLocalIpAddresses() async { + final ips = {}; + try { + for (final iface in await NetworkInterface.list()) { + for (final addr in iface.addresses) { + if (addr.type == InternetAddressType.IPv4) ips.add(addr.address); + } + } + } catch (e) { + debugPrint('❌ [NetworkScanner] Error getting local IPs: $e'); + } + return ips; + } + + Future> _getLocalNetworkRange() async { + try { + for (final iface in await NetworkInterface.list()) { + for (final addr in iface.addresses) { + if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) { + final parts = addr.address.split('.'); + if (parts.length == 4) { + final subnet = '${parts[0]}.${parts[1]}.${parts[2]}'; + debugPrint('📡 [NetworkScanner] Scanning subnet $subnet.0/24'); + return [for (int i = 1; i <= 254; i++) '$subnet.$i']; + } + } + } + } + } catch (e) { + debugPrint('❌ [NetworkScanner] Error getting network range: $e'); + } + return []; + } + + /// Try a raw TCP connect to check if the MeshCore TCP server is listening. + Future _checkDevice( + String ip, + int port, { + String? name, + }) async { + final sw = Stopwatch()..start(); + Socket? socket; + try { + socket = await Socket.connect( + ip, + port, + timeout: connectTimeout, + ); + sw.stop(); + debugPrint( + '✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)'); + return DiscoveredServer( + name: (name ?? ip).trim(), + ipAddress: ip, + port: port, + responseTime: sw.elapsedMilliseconds, + ); + } on SocketException { + // Connection refused or timed out — no device here + } catch (e) { + debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port — $e'); + } finally { + socket?.destroy(); + } + return null; + } + + // ── mDNS discovery ───────────────────────────────────────────────────────── + + Future> _discoverViaMdns({ + int? port, + required int scanSession, + }) async { + final scanPort = port ?? defaultPort; + final found = []; + + try { + debugPrint('🔍 [NetworkScanner] mDNS discovery for $serviceType...'); + final localIps = await _getLocalIpAddresses(); + + _activeDiscovery = await startDiscovery( + serviceType, + ipLookupType: IpLookupType.any, + ); + + 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, + name: service.name, + ); + if (result != null) { + found.add(result); + onServerDiscovered?.call(result); + } + } + } + + await stopDiscovery(_activeDiscovery!); + _activeDiscovery = null; + debugPrint( + '✅ [NetworkScanner] mDNS done. Found ${found.length} devices.'); + } catch (e) { + debugPrint('⚠️ [NetworkScanner] mDNS failed: $e'); + if (_activeDiscovery != null) { + try { + await stopDiscovery(_activeDiscovery!); + } catch (_) {} + _activeDiscovery = null; + } + } + + return found; + } + + // ── Port scan fallback ───────────────────────────────────────────────────── + + Future> _scanByPort({ + int? port, + required int scanSession, + }) async { + final scanPort = port ?? defaultPort; + final found = []; + + final localIps = await _getLocalIpAddresses(); + final ips = await _getLocalNetworkRange(); + if (ips.isEmpty) return []; + + debugPrint( + '🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort'); + + int scanned = 0; + 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, name: ip)), + ); + + for (final result in results) { + if (result != null && !localIps.contains(result.ipAddress)) { + found.add(result); + onServerDiscovered?.call(result); + } + } + + scanned += batch.length; + onProgressUpdate?.call(scanned, ips.length); + } + + return found; + } + + // ── Public API ───────────────────────────────────────────────────────────── + + /// Scan for MeshCore WiFi devices. Tries mDNS first, falls back to port scan. + Future> scan({int? port}) async { + if (_isScanning) return []; + _isScanning = true; + final scanSession = ++_scanSession; + + try { + 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, scanSession: scanSession); + } + if (_isScanSessionActive(scanSession)) { + _cachedServers = found; + } + return found; + } finally { + if (_scanSession == scanSession) { + _isScanning = false; + } + } + } + + /// Verify a previously discovered device is still reachable. + Future verifyServer(DiscoveredServer server) async { + final result = await _checkDevice( + server.ipAddress, + server.port, + name: server.name, + ); + return result != null; + } + + void clearCache() => _cachedServers = []; + + 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'); + }), + ); + } + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..9ce28e2 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,1000 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:timezone/data/latest_all.dart' as tz; +import '../models/sar_marker.dart'; +import '../l10n/app_localizations.dart'; + +/// Notification Service - manages urgent notifications for SAR messages +/// Provides critical alert functionality for SAR marker events +class NotificationService { + static final NotificationService _instance = NotificationService._internal(); + factory NotificationService() => _instance; + NotificationService._internal(); + + static const bool _skipPermissionRequests = bool.fromEnvironment( + 'MESHCORE_SCREENSHOTS', + ); + final FlutterLocalNotificationsPlugin _notificationsPlugin = + FlutterLocalNotificationsPlugin(); + static const String _prefMessagesEnabled = 'notifications_messages_enabled'; + static const String _prefSarEnabled = 'notifications_sar_enabled'; + static const String _prefDiscoveryEnabled = 'notifications_discovery_enabled'; + static const String _prefUpdatesEnabled = 'notifications_updates_enabled'; + static const String _prefMuteForeground = 'notifications_mute_foreground'; + + bool _isInitialized = false; + bool _permissionGranted = false; + bool _messageNotificationsEnabled = true; + bool _sarNotificationsEnabled = true; + bool _discoveryNotificationsEnabled = true; + bool _updateNotificationsEnabled = true; + bool _muteForegroundNotifications = true; + AppLifecycleState _lifecycleState = AppLifecycleState.resumed; + String? _launchPayload; + Timer? _messageNotificationTimer; + int _pendingMessageNotificationCount = 0; + String? _pendingMessageSenderName; + String? _pendingMessageText; + bool _pendingMessageIsChannel = false; + String? _pendingMessageChannelName; + AppLocalizations? _pendingMessageLocalizations; + + // Notification IDs + static const int _sarNotificationId = 1000; + static const int _messageNotificationId = 2000; + static const int _updateNotificationId = 3000; + static const int _batteryNotificationId = 4000; + static const int _discoveryNotificationId = 5000; + static const Duration _messageNotificationCoalesceWindow = + Duration(seconds: 2); + + // Notification channels + static const String _urgentChannelId = 'sar_urgent'; + static const String _urgentChannelName = 'SAR Urgent Alerts'; + static const String _urgentChannelDescription = + 'Critical alerts for SAR markers (found persons, fires, staging areas)'; + + static const String _messagesChannelId = 'messages'; + static const String _messagesChannelName = 'Messages'; + static const String _messagesChannelDescription = + 'Notifications for incoming messages from contacts and channels'; + + static const String _updateChannelId = 'app_updates'; + static const String _updateChannelName = 'App Updates'; + static const String _updateChannelDescription = + 'Notifications for available app updates'; + + static const String _batteryChannelId = 'battery_alerts'; + static const String _batteryChannelName = 'Battery Alerts'; + static const String _batteryChannelDescription = + 'Notifications when a device or contact battery is low'; + + static const String _discoveryChannelId = 'discovery_alerts'; + static const String _discoveryChannelName = 'Discovery Alerts'; + static const String _discoveryChannelDescription = + 'Notifications when new contacts are discovered'; + + bool get messageNotificationsEnabled => _messageNotificationsEnabled; + bool get sarNotificationsEnabled => _sarNotificationsEnabled; + bool get discoveryNotificationsEnabled => _discoveryNotificationsEnabled; + bool get updateNotificationsEnabled => _updateNotificationsEnabled; + bool get muteForegroundNotifications => _muteForegroundNotifications; + bool get isAppInForeground => _lifecycleState == AppLifecycleState.resumed; + + /// Initialize notification service + Future initialize() async { + if (_isInitialized) return; + + try { + debugPrint('📬 [NotificationService] Initializing...'); + WidgetsBinding.instance.addObserver(_LifecycleObserver(this)); + _lifecycleState = + WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed; + + // Initialize timezone data + tz.initializeTimeZones(); + + // Android initialization settings + const androidSettings = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); + + // iOS initialization settings + final darwinSettings = DarwinInitializationSettings( + requestAlertPermission: !_skipPermissionRequests, + requestBadgePermission: !_skipPermissionRequests, + requestSoundPermission: !_skipPermissionRequests, + ); + + // Combined initialization settings + final initSettings = InitializationSettings( + android: androidSettings, + iOS: darwinSettings, + ); + + // Initialize plugin + await _notificationsPlugin.initialize( + settings: initSettings, + onDidReceiveNotificationResponse: _onNotificationResponse, + ); + + final launchDetails = await _notificationsPlugin + .getNotificationAppLaunchDetails(); + if (launchDetails?.didNotificationLaunchApp ?? false) { + _launchPayload = launchDetails?.notificationResponse?.payload; + } + + // Request permissions + if (_skipPermissionRequests) { + _permissionGranted = true; + } else { + await _requestPermissions(); + } + await _loadPreferences(); + + // Create notification channels (Android) + await _createNotificationChannels(); + + _isInitialized = true; + debugPrint('✅ [NotificationService] Initialized successfully'); + debugPrint(' Permission granted: $_permissionGranted'); + } catch (e) { + debugPrint('❌ [NotificationService] Initialization error: $e'); + } + } + + /// Request notification permissions + Future _requestPermissions() async { + try { + // iOS permissions + final iosPlugin = _notificationsPlugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >(); + if (iosPlugin != null) { + final granted = await iosPlugin.requestPermissions( + alert: true, + badge: true, + sound: true, + ); + _permissionGranted = granted ?? false; + debugPrint( + '📱 [NotificationService] iOS permissions granted: $_permissionGranted', + ); + return; // Exit early if on iOS + } + + // Android 13+ permissions + final androidPlugin = _notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + if (androidPlugin != null) { + final granted = await androidPlugin.requestNotificationsPermission(); + _permissionGranted = granted ?? false; + debugPrint( + '🤖 [NotificationService] Android permissions granted: $_permissionGranted', + ); + return; // Exit early if on Android + } + + // If neither platform plugin is available, assume permissions are granted + // This handles older Android versions that don't require runtime permissions + _permissionGranted = true; + debugPrint( + '✅ [NotificationService] No platform plugin found, assuming permissions granted', + ); + } catch (e) { + debugPrint('⚠️ [NotificationService] Error requesting permissions: $e'); + } + } + + Future _loadPreferences() async { + final prefs = await SharedPreferences.getInstance(); + _messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true; + _sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true; + _discoveryNotificationsEnabled = + prefs.getBool(_prefDiscoveryEnabled) ?? true; + _updateNotificationsEnabled = prefs.getBool(_prefUpdatesEnabled) ?? true; + _muteForegroundNotifications = prefs.getBool(_prefMuteForeground) ?? true; + } + + Future setMessageNotificationsEnabled(bool value) async { + _messageNotificationsEnabled = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefMessagesEnabled, value); + } + + Future setSarNotificationsEnabled(bool value) async { + _sarNotificationsEnabled = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefSarEnabled, value); + } + + Future setDiscoveryNotificationsEnabled(bool value) async { + _discoveryNotificationsEnabled = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefDiscoveryEnabled, value); + } + + Future setUpdateNotificationsEnabled(bool value) async { + _updateNotificationsEnabled = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefUpdatesEnabled, value); + } + + Future setMuteForegroundNotifications(bool value) async { + _muteForegroundNotifications = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefMuteForeground, value); + } + + void handleAppLifecycleStateChanged(AppLifecycleState state) { + _lifecycleState = state; + } + + bool _shouldSuppressForegroundNotifications() { + return _muteForegroundNotifications && isAppInForeground; + } + + /// Create notification channels for Android + Future _createNotificationChannels() async { + try { + final androidPlugin = _notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + + if (androidPlugin == null) return; + + // Urgent SAR channel with maximum priority + const urgentChannel = AndroidNotificationChannel( + _urgentChannelId, + _urgentChannelName, + description: _urgentChannelDescription, + importance: Importance.max, + playSound: true, + enableVibration: true, + enableLights: true, + showBadge: true, + sound: RawResourceAndroidNotificationSound('notification'), + ); + + // Messages channel with high priority + const messagesChannel = AndroidNotificationChannel( + _messagesChannelId, + _messagesChannelName, + description: _messagesChannelDescription, + importance: Importance.high, + playSound: true, + enableVibration: true, + showBadge: true, + ); + + // App updates channel with default priority + const updateChannel = AndroidNotificationChannel( + _updateChannelId, + _updateChannelName, + description: _updateChannelDescription, + importance: Importance.defaultImportance, + playSound: false, + enableVibration: false, + showBadge: true, + ); + + const batteryChannel = AndroidNotificationChannel( + _batteryChannelId, + _batteryChannelName, + description: _batteryChannelDescription, + importance: Importance.high, + playSound: true, + enableVibration: true, + showBadge: true, + ); + + const discoveryChannel = AndroidNotificationChannel( + _discoveryChannelId, + _discoveryChannelName, + description: _discoveryChannelDescription, + importance: Importance.high, + playSound: true, + enableVibration: true, + showBadge: true, + ); + + await androidPlugin.createNotificationChannel(urgentChannel); + await androidPlugin.createNotificationChannel(messagesChannel); + await androidPlugin.createNotificationChannel(updateChannel); + await androidPlugin.createNotificationChannel(batteryChannel); + await androidPlugin.createNotificationChannel(discoveryChannel); + debugPrint('✅ [NotificationService] Created notification channels'); + } catch (e) { + debugPrint('⚠️ [NotificationService] Error creating channels: $e'); + } + } + + /// Callback for handling notification taps (set by main.dart) + void Function(String?)? onNotificationTapped; + + /// Handle notification tap (foreground) + void _onNotificationResponse(NotificationResponse response) { + debugPrint( + '🔔 [NotificationService] Notification tapped: ${response.payload}', + ); + + // Call the registered callback if available + if (onNotificationTapped != null) { + onNotificationTapped!(response.payload); + } + } + + String? consumeLaunchPayload() { + final payload = _launchPayload; + _launchPayload = null; + return payload; + } + + /// Show urgent notification for SAR marker + Future showSarNotification({ + required SarMarkerType type, + required String senderName, + required String coordinates, + String? notes, + AppLocalizations? localizations, + }) async { + if (_skipPermissionRequests) return; + + if (!_isInitialized) { + debugPrint( + '⚠️ [NotificationService] Not initialized, skipping notification', + ); + return; + } + + if (!_permissionGranted) { + debugPrint( + '⚠️ [NotificationService] Permission not granted, skipping notification', + ); + return; + } + if (!_sarNotificationsEnabled) { + debugPrint('ℹ️ [NotificationService] SAR notifications disabled'); + return; + } + if (_shouldSuppressForegroundNotifications()) { + debugPrint( + 'ℹ️ [NotificationService] App in foreground, skipping SAR notification', + ); + return; + } + + try { + // Generate unique notification ID based on timestamp + final notificationId = + _sarNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000); + + // Build notification title and body + final title = _buildNotificationTitle(type, localizations); + final body = _buildNotificationBody( + type: type, + senderName: senderName, + coordinates: coordinates, + notes: notes, + localizations: localizations, + ); + + // Android notification details + final androidDetails = AndroidNotificationDetails( + _urgentChannelId, + _urgentChannelName, + channelDescription: _urgentChannelDescription, + importance: Importance.max, + priority: Priority.high, + ticker: title, + playSound: true, + enableVibration: true, + enableLights: true, + color: Color(_getNotificationColor(type)), + colorized: true, + showWhen: true, + when: DateTime.now().millisecondsSinceEpoch, + category: AndroidNotificationCategory.alarm, // High priority category + fullScreenIntent: true, // Show as full screen on some devices + styleInformation: BigTextStyleInformation( + body, + contentTitle: title, + summaryText: _getSummaryText(type, localizations), + ), + ); + + // iOS notification details + final darwinDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + sound: 'default', + badgeNumber: 1, + threadIdentifier: 'sar_markers', + categoryIdentifier: 'SAR_ALERT', + interruptionLevel: InterruptionLevel.timeSensitive, + ); + + // Combined notification details + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: darwinDetails, + ); + + // Show notification + await _notificationsPlugin.show( + id: notificationId, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: 'sar:${type.name}:$coordinates', + ); + + debugPrint('✅ [NotificationService] Showed SAR notification: $title'); + debugPrint(' Type: ${type.displayName}'); + debugPrint(' Sender: $senderName'); + debugPrint(' Coordinates: $coordinates'); + } catch (e) { + debugPrint('❌ [NotificationService] Error showing notification: $e'); + } + } + + /// Build notification title based on SAR marker type + String _buildNotificationTitle( + SarMarkerType type, + AppLocalizations? localizations, + ) { + if (localizations == null) { + return '${type.emoji} ${type.displayName} Detected'; + } + + switch (type) { + case SarMarkerType.foundPerson: + return '${type.emoji} ${localizations.sarMarkerFoundPerson}'; + case SarMarkerType.fire: + return '${type.emoji} ${localizations.sarMarkerFire}'; + case SarMarkerType.stagingArea: + return '${type.emoji} ${localizations.sarMarkerStagingArea}'; + case SarMarkerType.object: + return '${type.emoji} ${localizations.sarMarkerObject}'; + case SarMarkerType.unknown: + return '${type.emoji} ${localizations.sarAlert}'; + } + } + + /// Build notification body with all details + String _buildNotificationBody({ + required SarMarkerType type, + required String senderName, + required String coordinates, + String? notes, + AppLocalizations? localizations, + }) { + final buffer = StringBuffer(); + + // Sender + if (localizations != null) { + buffer.write('${localizations.from}: $senderName\n'); + buffer.write('${localizations.coordinates}: $coordinates'); + } else { + buffer.write('From: $senderName\n'); + buffer.write('Coordinates: $coordinates'); + } + + // Optional notes + if (notes != null && notes.isNotEmpty) { + buffer.write('\n\n$notes'); + } + + return buffer.toString(); + } + + /// Get summary text for notification + String _getSummaryText(SarMarkerType type, AppLocalizations? localizations) { + if (localizations == null) { + return 'Tap to view on map'; + } + return localizations.tapToViewOnMap; + } + + /// Get notification color based on SAR marker type + int _getNotificationColor(SarMarkerType type) { + // Return ARGB color codes + switch (type) { + case SarMarkerType.foundPerson: + return 0xFF4CAF50; // Green + case SarMarkerType.fire: + return 0xFFF44336; // Red + case SarMarkerType.stagingArea: + return 0xFFFF9800; // Orange + case SarMarkerType.object: + return 0xFF2196F3; // Blue + case SarMarkerType.unknown: + return 0xFF9E9E9E; // Gray + } + } + + /// Show notification for regular message (contact or channel) + Future showMessageNotification({ + required String senderName, + required String messageText, + required bool isChannelMessage, + String? channelName, + AppLocalizations? localizations, + }) async { + if (_skipPermissionRequests) return; + + if (!_isInitialized) { + debugPrint( + '⚠️ [NotificationService] Not initialized, skipping notification', + ); + return; + } + + if (!_permissionGranted) { + debugPrint( + '⚠️ [NotificationService] Permission not granted, skipping notification', + ); + return; + } + if (!_messageNotificationsEnabled) { + debugPrint('ℹ️ [NotificationService] Message notifications disabled'); + return; + } + if (_shouldSuppressForegroundNotifications()) { + debugPrint( + 'ℹ️ [NotificationService] App in foreground, skipping message notification', + ); + return; + } + + _pendingMessageNotificationCount++; + _pendingMessageSenderName = senderName; + _pendingMessageText = messageText; + _pendingMessageIsChannel = isChannelMessage; + _pendingMessageChannelName = channelName; + _pendingMessageLocalizations = localizations; + + _messageNotificationTimer?.cancel(); + _messageNotificationTimer = Timer(_messageNotificationCoalesceWindow, () { + unawaited(_flushMessageNotification()); + }); + } + + Future _flushMessageNotification() async { + final count = _pendingMessageNotificationCount; + final senderName = _pendingMessageSenderName; + final messageText = _pendingMessageText; + final isChannelMessage = _pendingMessageIsChannel; + final channelName = _pendingMessageChannelName; + final localizations = _pendingMessageLocalizations; + + _pendingMessageNotificationCount = 0; + _pendingMessageSenderName = null; + _pendingMessageText = null; + _pendingMessageIsChannel = false; + _pendingMessageChannelName = null; + _pendingMessageLocalizations = null; + + if (count == 0 || senderName == null || messageText == null) { + return; + } + + if (!_isInitialized || + !_permissionGranted || + !_messageNotificationsEnabled || + _shouldSuppressForegroundNotifications()) { + return; + } + + try { + // Build notification title and body + final resolvedChannelName = channelName?.trim().isNotEmpty == true + ? channelName!.trim() + : (localizations?.publicChannel ?? 'Public'); + final title = count > 1 + ? (localizations?.messages ?? 'Messages') + : isChannelMessage + ? (localizations != null + ? '${localizations.channel}: $resolvedChannelName' + : 'Channel: $resolvedChannelName') + : (localizations != null + ? '${localizations.newMessage} ${localizations.from} $senderName' + : 'New message from $senderName'); + + final body = count > 1 + ? 'You have $count new messages' + : messageText.length > 200 + ? '${messageText.substring(0, 200)}...' + : messageText; + + // Android notification details + final androidDetails = AndroidNotificationDetails( + _messagesChannelId, + _messagesChannelName, + channelDescription: _messagesChannelDescription, + importance: Importance.high, + priority: Priority.high, + ticker: title, + playSound: true, + enableVibration: true, + showWhen: true, + when: DateTime.now().millisecondsSinceEpoch, + styleInformation: BigTextStyleInformation( + body, + contentTitle: title, + summaryText: count > 1 ? body : senderName, + ), + ); + + // iOS notification details + final darwinDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + sound: 'default', + threadIdentifier: isChannelMessage + ? 'channel_messages' + : 'direct_messages', + subtitle: senderName, + ); + + // Combined notification details + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: darwinDetails, + ); + + // Show notification + await _notificationsPlugin.show( + id: _messageNotificationId, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: 'message:${isChannelMessage ? "channel" : "contact"}', + ); + + debugPrint('✅ [NotificationService] Showed message notification'); + debugPrint(' Count: $count'); + debugPrint(' Sender: $senderName'); + debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}'); + } catch (e) { + debugPrint( + '❌ [NotificationService] Error showing message notification: $e', + ); + } + } + + /// Cancel all notifications + Future cancelAll() async { + try { + await _notificationsPlugin.cancelAll(); + debugPrint('✅ [NotificationService] Cancelled all notifications'); + } catch (e) { + debugPrint('❌ [NotificationService] Error canceling notifications: $e'); + } + } + + /// Cancel specific notification + Future cancel(int id) async { + try { + await _notificationsPlugin.cancel(id: id); + debugPrint('✅ [NotificationService] Cancelled notification: $id'); + } catch (e) { + debugPrint('❌ [NotificationService] Error canceling notification: $e'); + } + } + + /// Check if notifications are enabled + Future areNotificationsEnabled() async { + try { + final androidPlugin = _notificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + if (androidPlugin != null) { + final enabled = await androidPlugin.areNotificationsEnabled(); + return enabled ?? false; + } + + // For iOS, assume enabled if permission was granted + return _permissionGranted; + } catch (e) { + debugPrint( + '⚠️ [NotificationService] Error checking notification status: $e', + ); + return false; + } + } + + /// Get pending notifications + Future> getPendingNotifications() async { + try { + return await _notificationsPlugin.pendingNotificationRequests(); + } catch (e) { + debugPrint( + '⚠️ [NotificationService] Error getting pending notifications: $e', + ); + return []; + } + } + + /// Show notification for available app update + Future showUpdateNotification({ + required String currentVersion, + required String latestVersion, + required String downloadUrl, + AppLocalizations? localizations, + }) async { + if (_skipPermissionRequests) return; + + if (!_isInitialized) { + debugPrint( + '⚠️ [NotificationService] Not initialized, skipping notification', + ); + return; + } + + if (!_permissionGranted) { + debugPrint( + '⚠️ [NotificationService] Permission not granted, skipping notification', + ); + return; + } + if (!_updateNotificationsEnabled) { + debugPrint('ℹ️ [NotificationService] Update notifications disabled'); + return; + } + if (_shouldSuppressForegroundNotifications()) { + debugPrint( + 'ℹ️ [NotificationService] App in foreground, skipping update notification', + ); + return; + } + + try { + // Build notification title and body + final title = localizations?.updateAvailable ?? 'App Update Available'; + final body = localizations != null + ? '${localizations.currentVersion}: $currentVersion\n' + '${localizations.latestVersion}: $latestVersion' + : 'Current: $currentVersion\nLatest: $latestVersion'; + + // Android notification details + final androidDetails = AndroidNotificationDetails( + _updateChannelId, + _updateChannelName, + channelDescription: _updateChannelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + ticker: title, + playSound: false, + enableVibration: false, + showWhen: true, + when: DateTime.now().millisecondsSinceEpoch, + icon: '@mipmap/ic_launcher', + color: const Color(0xFF2196F3), // Blue + colorized: true, + category: AndroidNotificationCategory.recommendation, + styleInformation: BigTextStyleInformation( + body, + contentTitle: title, + summaryText: localizations?.downloadUpdate ?? 'Tap to download', + ), + // Make notification ongoing so it doesn't get dismissed easily + ongoing: false, + autoCancel: true, + ); + + // iOS notification details + final darwinDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: false, + threadIdentifier: 'app_updates', + categoryIdentifier: 'APP_UPDATE', + subtitle: localizations?.newVersionAvailable(latestVersion) ?? 'New version: $latestVersion', + ); + + // Combined notification details + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: darwinDetails, + ); + + // Show notification + await _notificationsPlugin.show( + id: _updateNotificationId, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: 'update:$downloadUrl', + ); + + debugPrint('✅ [NotificationService] Showed update notification'); + debugPrint(' Current: $currentVersion'); + debugPrint(' Latest: $latestVersion'); + debugPrint(' Download URL: $downloadUrl'); + } catch (e) { + debugPrint( + '❌ [NotificationService] Error showing update notification: $e', + ); + } + } + + Future showLowBatteryNotification({ + required String nodeId, + required String nodeName, + required double batteryPercent, + required bool isCurrentDevice, + }) async { + if (_skipPermissionRequests) return false; + + if (!_isInitialized) { + debugPrint( + '⚠️ [NotificationService] Not initialized, skipping notification', + ); + return false; + } + + if (!_permissionGranted) { + debugPrint( + '⚠️ [NotificationService] Permission not granted, skipping notification', + ); + return false; + } + if (!_messageNotificationsEnabled) { + debugPrint('ℹ️ [NotificationService] Message notifications disabled'); + return false; + } + if (_shouldSuppressForegroundNotifications()) { + debugPrint( + 'ℹ️ [NotificationService] App in foreground, skipping low battery notification', + ); + return false; + } + + final roundedPercent = batteryPercent.round().clamp(0, 100); + final title = isCurrentDevice + ? 'Device battery low' + : 'Contact battery low'; + final body = isCurrentDevice + ? '$nodeName battery is at $roundedPercent%.' + : '$nodeName is at $roundedPercent% battery.'; + final notificationId = + _batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000); + + try { + final androidDetails = AndroidNotificationDetails( + _batteryChannelId, + _batteryChannelName, + channelDescription: _batteryChannelDescription, + importance: Importance.high, + priority: Priority.high, + ticker: title, + playSound: true, + enableVibration: true, + showWhen: true, + when: DateTime.now().millisecondsSinceEpoch, + styleInformation: BigTextStyleInformation( + body, + contentTitle: title, + summaryText: '$roundedPercent%', + ), + ); + + final darwinDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + sound: 'default', + threadIdentifier: 'battery_alerts', + subtitle: '$roundedPercent%', + ); + + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: darwinDetails, + ); + + await _notificationsPlugin.show( + id: notificationId, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: 'battery:$nodeId', + ); + + debugPrint( + '✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)', + ); + return true; + } catch (e) { + debugPrint( + '❌ [NotificationService] Error showing low battery notification: $e', + ); + return false; + } + } + + Future showContactDiscoveredNotification({ + required String contactKey, + String? contactName, + }) async { + if (_skipPermissionRequests) return false; + + if (!_isInitialized) return false; + if (!_permissionGranted) return false; + if (!_discoveryNotificationsEnabled) return false; + if (_shouldSuppressForegroundNotifications()) return false; + + final shortKey = contactKey.length > 12 + ? contactKey.substring(0, 12).toUpperCase() + : contactKey.toUpperCase(); + final title = 'New contact discovered'; + final resolvedName = contactName?.trim(); + final body = resolvedName != null && resolvedName.isNotEmpty + ? '$resolvedName is available in Discovery.' + : 'New contact $shortKey is available in Discovery.'; + final notificationId = + _discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000); + + try { + final androidDetails = AndroidNotificationDetails( + _discoveryChannelId, + _discoveryChannelName, + channelDescription: _discoveryChannelDescription, + importance: Importance.high, + priority: Priority.high, + ticker: title, + playSound: true, + enableVibration: true, + showWhen: true, + when: DateTime.now().millisecondsSinceEpoch, + ); + + final darwinDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + sound: 'default', + threadIdentifier: 'discovery_alerts', + ); + + await _notificationsPlugin.show( + id: notificationId, + title: title, + body: body, + notificationDetails: NotificationDetails( + android: androidDetails, + iOS: darwinDetails, + ), + payload: 'discovery:$contactKey', + ); + return true; + } catch (e) { + debugPrint( + '❌ [NotificationService] Error showing discovery notification: $e', + ); + return false; + } + } +} + +class _LifecycleObserver with WidgetsBindingObserver { + _LifecycleObserver(this._service); + + final NotificationService _service; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _service.handleAppLifecycleStateChanged(state); + } +} diff --git a/lib/services/offline_map_caching_provider.dart b/lib/services/offline_map_caching_provider.dart new file mode 100644 index 0000000..ae89227 --- /dev/null +++ b/lib/services/offline_map_caching_provider.dart @@ -0,0 +1,184 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import 'offline_tile_cache_service.dart'; + +/// A [MapCachingProvider] that checks the offline 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 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 offline cache first + final cachedTile = await _cache.getTileData( + styleHash, + coords.z, + coords.x, + coords.y, + ); + if (cachedTile != null) { + return ( + bytes: cachedTile.bytes, + 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 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.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}); +} diff --git a/lib/services/offline_tile_cache_service.dart b/lib/services/offline_tile_cache_service.dart new file mode 100644 index 0000000..194ee3d --- /dev/null +++ b/lib/services/offline_tile_cache_service.dart @@ -0,0 +1,939 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; + +/// A saved download region (polygons + zoom range) for quick re-download. +class DownloadRegion { + final List>> polygons; // [polygon][vertex][lat, lng] + final int minZoom; + final int maxZoom; + + const DownloadRegion({ + required this.polygons, + required this.minZoom, + required this.maxZoom, + }); + + Map toJson() => { + 'polygons': polygons, + 'minZoom': minZoom, + 'maxZoom': maxZoom, + }; + + factory DownloadRegion.fromJson(Map json) { + final rawPolygons = json['polygons'] as List? ?? []; + final polygons = rawPolygons.map>>((poly) { + return (poly as List).map>((vertex) { + final v = vertex as List; + 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 toJson() => { + 'hash': hash, + 'displayName': displayName, + 'urlTemplate': urlTemplate, + 'tileCount': tileCount, + 'sizeBytes': sizeBytes, + if (region != null) 'region': region!.toJson(), + }; + + factory StyleInfo.fromJson(Map 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) + : 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 toJson() => {'z': z, 'x': x, 'y': y}; + + factory CachedTileCoord.fromJson(Map json) => + CachedTileCoord( + json['z'] as int, + json['x'] as int, + json['y'] as int, + ); +} + +class CachedTileData { + final Uint8List bytes; + final String? contentType; + + const CachedTileData({ + required this.bytes, + required this.contentType, + }); +} + +class _CachedTileLocation { + final String relativePath; + final String? contentType; + + const _CachedTileLocation({ + required this.relativePath, + required this.contentType, + }); +} + +/// Manages the offline tile cache on disk with a SQLite metadata index. +class OfflineTileCacheService { + OfflineTileCacheService._(); + static final instance = OfflineTileCacheService._(); + + static const int _memoryCacheMaxEntries = 4096; + static const int _memoryCacheMaxBytes = 64 * 1024 * 1024; + + String? _baseDir; + String? _databasePath; + Database? _database; + int _tileMemoryBytes = 0; + + final Map> _manifests = {}; + final Map> _styleHydrations = {}; + final Map _tileLocationCache = {}; + final LinkedHashMap _tileMemoryCache = + LinkedHashMap(); + + Future get baseDir async { + if (_baseDir != null) return _baseDir!; + final docs = await getApplicationDocumentsDirectory(); + _baseDir = '${docs.path}/offline_tiles'; + return _baseDir!; + } + + Future get _dbPath async { + if (_databasePath != null) return _databasePath!; + final docs = await getApplicationSupportDirectory(); + _databasePath = '${docs.path}/offline_tiles.db'; + return _databasePath!; + } + + Future get _db async { + if (_database != null) return _database!; + final path = await _dbPath; + _database = await openDatabase( + path, + version: 1, + onCreate: (db, version) async { + await _createSchema(db); + }, + ); + return _database!; + } + + @visibleForTesting + void setBaseDirForTesting(String path) { + _baseDir = path; + } + + @visibleForTesting + void setDatabasePathForTesting(String path) { + _databasePath = path; + } + + @visibleForTesting + Future resetForTesting() async { + await _database?.close(); + _database = null; + _baseDir = null; + _databasePath = null; + _manifests.clear(); + clearMemoryCacheForTesting(); + } + + @visibleForTesting + void clearMemoryCacheForTesting() { + _tileLocationCache.clear(); + _tileMemoryCache.clear(); + _tileMemoryBytes = 0; + } + + /// 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(); + } + + static String _tileKey(int z, int x, int y) => '$z/$x/$y'; + + String _tileDir(String base, String styleHash, int z, int x) { + return '$base/$styleHash/$z/$x'; + } + + String _manifestPath(String base, String styleHash) => + '$base/$styleHash/manifest.txt'; + + Future _createSchema(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS offline_tile_styles ( + style_hash TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + url_template TEXT NOT NULL, + region_json TEXT, + tile_count INTEGER NOT NULL DEFAULT 0, + size_bytes INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS offline_tile_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + style_hash TEXT NOT NULL, + z INTEGER NOT NULL, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + relative_path TEXT NOT NULL, + content_type TEXT, + size_bytes INTEGER NOT NULL, + cached_at INTEGER NOT NULL, + UNIQUE(style_hash, z, x, y) + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS offline_tile_entries_style_idx ' + 'ON offline_tile_entries(style_hash)', + ); + } + + /// Load the manifest for a style into memory (if not already loaded). + Future> loadManifest(String styleHash) async { + if (_manifests.containsKey(styleHash)) return _manifests[styleHash]!; + + var rows = await (await _db).query( + 'offline_tile_entries', + columns: ['z', 'x', 'y'], + where: 'style_hash = ?', + whereArgs: [styleHash], + ); + if (rows.isEmpty) { + await _hydrateStyleFromDisk(styleHash); + rows = await (await _db).query( + 'offline_tile_entries', + columns: ['z', 'x', 'y'], + where: 'style_hash = ?', + whereArgs: [styleHash], + ); + } + + final manifest = rows + .map((row) => _tileKey( + row['z'] as int, + row['x'] as int, + row['y'] as int, + )) + .toSet(); + _manifests[styleHash] = manifest; + return manifest; + } + + Future getTileData( + String styleHash, + int z, + int x, + int y, + ) async { + final cacheKey = '$styleHash/${_tileKey(z, x, y)}'; + final cached = _tileMemoryCache.remove(cacheKey); + if (cached != null) { + _tileMemoryCache[cacheKey] = cached; + return cached; + } + + var location = _tileLocationCache[cacheKey]; + if (location == null) { + final row = await _loadTileRow(styleHash, z, x, y); + if (row == null) return null; + location = _CachedTileLocation( + relativePath: row['relative_path'] as String, + contentType: row['content_type'] as String?, + ); + _tileLocationCache[cacheKey] = location; + } + + final base = await baseDir; + final file = File('$base/${location.relativePath}'); + if (!await file.exists()) { + await _deleteTileEntry(styleHash, z, x, y); + return null; + } + + final Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } on FileSystemException { + await _deleteTileEntry(styleHash, z, x, y); + return null; + } + final data = CachedTileData( + bytes: bytes, + contentType: location.contentType, + ); + _rememberTile(cacheKey, data); + return data; + } + + /// Read a cached tile's raw bytes (for serving to peers). + Future getRawTile(String styleHash, int z, int x, int y) async { + final tile = await getTileData(styleHash, z, x, y); + return tile?.bytes; + } + + /// Check if a tile exists in the cache. + Future hasTile(String styleHash, int z, int x, int y) async { + final cacheKey = '$styleHash/${_tileKey(z, x, y)}'; + if (_tileLocationCache.containsKey(cacheKey)) { + _manifests[styleHash] ??= {}; + _manifests[styleHash]!.add(_tileKey(z, x, y)); + return true; + } + + final row = await _loadTileRow(styleHash, z, x, y); + if (row != null) { + _tileLocationCache[cacheKey] = _CachedTileLocation( + relativePath: row['relative_path'] as String, + contentType: row['content_type'] as String?, + ); + _manifests[styleHash] ??= {}; + _manifests[styleHash]!.add(_tileKey(z, x, y)); + return true; + } + + final manifest = await loadManifest(styleHash); + return manifest.contains(_tileKey(z, x, y)); + } + + /// Store a tile's original bytes and update the SQLite index. + Future putTile( + String styleHash, + int z, + int x, + int y, + Uint8List bytes, { + String? contentType, + String? sourceUrl, + }) async { + final resolvedContentType = _normalizeContentType(contentType); + final fileExtension = _fileExtensionForTile( + contentType: resolvedContentType, + sourceUrl: sourceUrl, + ); + final relativePath = '$styleHash/$z/$x/$y.$fileExtension'; + final base = await baseDir; + final dir = Directory(_tileDir(base, styleHash, z, x)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + final file = File('$base/$relativePath'); + await file.writeAsBytes(bytes, flush: true); + await _upsertTileEntry( + styleHash: styleHash, + z: z, + x: x, + y: y, + relativePath: relativePath, + contentType: resolvedContentType, + sizeBytes: bytes.length, + ); + + _manifests[styleHash] ??= {}; + _manifests[styleHash]!.add(_tileKey(z, x, y)); + await _writeManifest(base, styleHash, _manifests[styleHash]!); + _rememberTile( + '$styleHash/${_tileKey(z, x, y)}', + CachedTileData(bytes: bytes, contentType: resolvedContentType), + ); + } + + /// Store raw tile bytes directly (from a peer), update the SQLite index. + Future putRawTile( + String styleHash, + int z, + int x, + int y, + Uint8List bytes, { + String? contentType, + }) async { + await putTile( + styleHash, + z, + x, + y, + bytes, + contentType: contentType, + ); + } + + /// Get total cache size in bytes. + Future getCacheSize() async { + await _hydrateAllStylesIfDatabaseIsEmpty(); + final rows = await (await _db).rawQuery( + 'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM offline_tile_entries', + ); + return (rows.first['total'] as int?) ?? 0; + } + + /// List all style hashes that have cached tiles. + Future> listStyles() async { + final styles = await listStylesDetailed(); + return styles.map((style) => style.hash).toList(); + } + + /// Save metadata for a style (name, URL template, download region). + Future 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); + } + + final metaFile = File('$base/$styleHash/meta.json'); + final existing = await getStyleMeta(styleHash); + final regionJson = region != null + ? jsonEncode(region.toJson()) + : existing?.region == null + ? null + : jsonEncode(existing!.region!.toJson()); + final meta = { + 'displayName': displayName, + 'urlTemplate': urlTemplate, + if (regionJson != null) 'region': jsonDecode(regionJson), + }; + await metaFile.writeAsString(jsonEncode(meta), flush: true); + + final now = DateTime.now().millisecondsSinceEpoch; + await (await _db).insert( + 'offline_tile_styles', + { + 'style_hash': styleHash, + 'display_name': displayName, + 'url_template': urlTemplate, + 'region_json': regionJson, + 'tile_count': existing?.tileCount ?? 0, + 'size_bytes': existing?.sizeBytes ?? 0, + 'updated_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Read metadata for a style. + Future getStyleMeta(String styleHash) async { + final rows = await (await _db).query( + 'offline_tile_styles', + where: 'style_hash = ?', + whereArgs: [styleHash], + limit: 1, + ); + if (rows.isNotEmpty) { + return _styleInfoFromRow(rows.first); + } + + 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) + : null, + ); + } catch (_) { + return null; + } + } + + /// List all styles with metadata, tile counts, and sizes. + Future> listStylesDetailed() async { + await _hydrateAllStylesIfDatabaseIsEmpty(); + final rows = await (await _db).query( + 'offline_tile_styles', + orderBy: 'updated_at DESC', + ); + return rows.map(_styleInfoFromRow).toList(); + } + + /// List all tile coordinates cached for a given style. + Future> listTilesForStyle(String styleHash) async { + var rows = await (await _db).query( + 'offline_tile_entries', + columns: ['z', 'x', 'y'], + where: 'style_hash = ?', + whereArgs: [styleHash], + orderBy: 'z ASC, x ASC, y ASC', + ); + if (rows.isEmpty) { + await _hydrateStyleFromDisk(styleHash); + rows = await (await _db).query( + 'offline_tile_entries', + columns: ['z', 'x', 'y'], + where: 'style_hash = ?', + whereArgs: [styleHash], + orderBy: 'z ASC, x ASC, y ASC', + ); + } + + return rows + .map((row) => CachedTileCoord( + row['z'] as int, + row['x'] as int, + row['y'] as int, + )) + .toList(); + } + + /// Delete a single style's tiles, manifest, and metadata. + Future deleteStyle(String styleHash) async { + _manifests.remove(styleHash); + _removeStyleFromMemory(styleHash); + final base = await baseDir; + final dir = Directory('$base/$styleHash'); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + await (await _db).delete( + 'offline_tile_entries', + where: 'style_hash = ?', + whereArgs: [styleHash], + ); + await (await _db).delete( + 'offline_tile_styles', + where: 'style_hash = ?', + whereArgs: [styleHash], + ); + } + + /// Delete all cached tiles, manifests, and metadata. + Future clearCache() async { + _manifests.clear(); + clearMemoryCacheForTesting(); + final base = await baseDir; + final dir = Directory(base); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + await (await _db).delete('offline_tile_entries'); + await (await _db).delete('offline_tile_styles'); + } + + StyleInfo _styleInfoFromRow(Map row) { + final regionJson = row['region_json'] as String?; + return StyleInfo( + hash: row['style_hash'] as String, + displayName: row['display_name'] as String, + urlTemplate: row['url_template'] as String, + tileCount: row['tile_count'] as int? ?? 0, + sizeBytes: row['size_bytes'] as int? ?? 0, + region: regionJson == null + ? null + : DownloadRegion.fromJson(jsonDecode(regionJson) as Map), + ); + } + + Future _hydrateAllStylesIfDatabaseIsEmpty() async { + final rows = await (await _db).rawQuery( + 'SELECT COUNT(*) AS count FROM offline_tile_styles', + ); + if (((rows.first['count'] as int?) ?? 0) > 0) return; + await _hydrateAllStylesFromDisk(); + } + + Future _hydrateAllStylesFromDisk() async { + final base = await baseDir; + final dir = Directory(base); + if (!await dir.exists()) return; + + await for (final entity in dir.list()) { + if (entity is Directory) { + final styleHash = entity.path.split('/').last; + await _hydrateStyleFromDisk(styleHash); + } + } + } + + Future _hydrateStyleFromDisk(String styleHash) { + final existing = _styleHydrations[styleHash]; + if (existing != null) return existing; + + final hydration = _hydrateStyleFromDiskLocked(styleHash); + _styleHydrations[styleHash] = hydration; + return hydration.whenComplete(() { + _styleHydrations.remove(styleHash); + }); + } + + Future _hydrateStyleFromDiskLocked(String styleHash) async { + final base = await baseDir; + final styleDir = Directory('$base/$styleHash'); + if (!await styleDir.exists()) return; + + String displayName = styleHash; + String urlTemplate = ''; + DownloadRegion? region; + final metaFile = File('$base/$styleHash/meta.json'); + if (await metaFile.exists()) { + try { + final json = jsonDecode(await metaFile.readAsString()); + displayName = json['displayName'] as String? ?? styleHash; + urlTemplate = json['urlTemplate'] as String? ?? ''; + if (json['region'] != null) { + region = DownloadRegion.fromJson(json['region'] as Map); + } + } catch (_) {} + } + + final entries = <({int z, int x, int y, String relativePath, String? contentType, int sizeBytes})>[]; + await for (final entity in styleDir.list(recursive: true)) { + if (entity is! File) continue; + final relativePath = entity.path.replaceFirst('$base/', ''); + final segments = relativePath.split('/'); + if (segments.length != 4) continue; + if (segments[3] == 'meta.json' || segments[3] == 'manifest.txt') continue; + + final z = int.tryParse(segments[1]); + final x = int.tryParse(segments[2]); + final ySegment = segments[3].split('.').first; + final y = int.tryParse(ySegment); + if (z == null || x == null || y == null) continue; + + final int sizeBytes; + try { + if (!await entity.exists()) continue; + sizeBytes = await entity.length(); + } on FileSystemException { + continue; + } + + entries.add(( + z: z, + x: x, + y: y, + relativePath: relativePath, + contentType: _contentTypeFromPath(relativePath), + sizeBytes: sizeBytes, + )); + } + + final now = DateTime.now().millisecondsSinceEpoch; + final totalSize = entries.fold( + 0, + (total, entry) => total + entry.sizeBytes, + ); + await (await _db).transaction((txn) async { + await txn.delete( + 'offline_tile_entries', + where: 'style_hash = ?', + whereArgs: [styleHash], + ); + await txn.insert( + 'offline_tile_styles', + { + 'style_hash': styleHash, + 'display_name': displayName, + 'url_template': urlTemplate, + 'region_json': region == null ? null : jsonEncode(region.toJson()), + 'tile_count': entries.length, + 'size_bytes': totalSize, + 'updated_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + for (final entry in entries) { + await txn.insert( + 'offline_tile_entries', + { + 'style_hash': styleHash, + 'z': entry.z, + 'x': entry.x, + 'y': entry.y, + 'relative_path': entry.relativePath, + 'content_type': entry.contentType, + 'size_bytes': entry.sizeBytes, + 'cached_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + }); + + _manifests[styleHash] = + entries.map((entry) => _tileKey(entry.z, entry.x, entry.y)).toSet(); + for (final entry in entries) { + _tileLocationCache['$styleHash/${_tileKey(entry.z, entry.x, entry.y)}'] = + _CachedTileLocation( + relativePath: entry.relativePath, + contentType: entry.contentType, + ); + } + await _writeManifest(base, styleHash, _manifests[styleHash]!); + } + + Future _upsertTileEntry({ + required String styleHash, + required int z, + required int x, + required int y, + required String relativePath, + required String? contentType, + required int sizeBytes, + }) async { + final existingTile = await _loadTileRow(styleHash, z, x, y, hydrate: false); + final existingStyle = await getStyleMeta(styleHash); + if (existingTile != null && + existingTile['relative_path'] != null && + existingTile['relative_path'] != relativePath) { + final base = await baseDir; + final oldFile = File('$base/${existingTile['relative_path']}'); + if (await oldFile.exists()) { + await oldFile.delete(); + } + } + + final tileCountDelta = existingTile == null ? 1 : 0; + final sizeDelta = sizeBytes - ((existingTile?['size_bytes'] as int?) ?? 0); + final now = DateTime.now().millisecondsSinceEpoch; + await (await _db).transaction((txn) async { + await txn.insert( + 'offline_tile_entries', + { + 'style_hash': styleHash, + 'z': z, + 'x': x, + 'y': y, + 'relative_path': relativePath, + 'content_type': contentType, + 'size_bytes': sizeBytes, + 'cached_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + await txn.insert( + 'offline_tile_styles', + { + 'style_hash': styleHash, + 'display_name': existingStyle?.displayName ?? styleHash, + 'url_template': existingStyle?.urlTemplate ?? '', + 'region_json': existingStyle?.region == null + ? null + : jsonEncode(existingStyle!.region!.toJson()), + 'tile_count': (existingStyle?.tileCount ?? 0) + tileCountDelta, + 'size_bytes': (existingStyle?.sizeBytes ?? 0) + sizeDelta, + 'updated_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + }); + + _tileLocationCache['$styleHash/${_tileKey(z, x, y)}'] = + _CachedTileLocation( + relativePath: relativePath, + contentType: contentType, + ); + } + + Future _deleteTileEntry(String styleHash, int z, int x, int y) async { + final existingTile = await _loadTileRow(styleHash, z, x, y, hydrate: false); + if (existingTile == null) return; + + final existingStyle = await getStyleMeta(styleHash); + final now = DateTime.now().millisecondsSinceEpoch; + await (await _db).transaction((txn) async { + await txn.delete( + 'offline_tile_entries', + where: 'style_hash = ? AND z = ? AND x = ? AND y = ?', + whereArgs: [styleHash, z, x, y], + ); + if (existingStyle != null) { + await txn.insert( + 'offline_tile_styles', + { + 'style_hash': styleHash, + 'display_name': existingStyle.displayName, + 'url_template': existingStyle.urlTemplate, + 'region_json': existingStyle.region == null + ? null + : jsonEncode(existingStyle.region!.toJson()), + 'tile_count': (existingStyle.tileCount - 1).clamp(0, existingStyle.tileCount), + 'size_bytes': (existingStyle.sizeBytes - (existingTile['size_bytes'] as int)) + .clamp(0, existingStyle.sizeBytes), + 'updated_at': now, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + }); + + _manifests[styleHash]?.remove(_tileKey(z, x, y)); + _tileLocationCache.remove('$styleHash/${_tileKey(z, x, y)}'); + final removed = _tileMemoryCache.remove('$styleHash/${_tileKey(z, x, y)}'); + if (removed != null) { + _tileMemoryBytes -= removed.bytes.length; + } + final base = await baseDir; + final manifest = _manifests[styleHash]; + if (manifest != null) { + await _writeManifest(base, styleHash, manifest); + } + } + + Future _writeManifest( + String base, String styleHash, Set 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); + } + + void _rememberTile(String key, CachedTileData data) { + final existing = _tileMemoryCache.remove(key); + if (existing != null) { + _tileMemoryBytes -= existing.bytes.length; + } + _tileMemoryCache[key] = data; + _tileMemoryBytes += data.bytes.length; + + while (_tileMemoryCache.length > _memoryCacheMaxEntries || + _tileMemoryBytes > _memoryCacheMaxBytes) { + final firstKey = _tileMemoryCache.keys.first; + final removed = _tileMemoryCache.remove(firstKey); + if (removed != null) { + _tileMemoryBytes -= removed.bytes.length; + } + } + } + + void _removeStyleFromMemory(String styleHash) { + final prefix = '$styleHash/'; + _tileLocationCache.removeWhere((key, _) => key.startsWith(prefix)); + final keys = _tileMemoryCache.keys.where((key) => key.startsWith(prefix)).toList(); + for (final key in keys) { + final removed = _tileMemoryCache.remove(key); + if (removed != null) { + _tileMemoryBytes -= removed.bytes.length; + } + } + } + + String? _normalizeContentType(String? contentType) { + if (contentType == null || contentType.isEmpty) return null; + return contentType.split(';').first.trim().toLowerCase(); + } + + String _fileExtensionForTile({ + required String? contentType, + required String? sourceUrl, + }) { + if (contentType != null) { + switch (contentType) { + case 'image/png': + return 'png'; + case 'image/jpeg': + return 'jpg'; + case 'image/webp': + return 'webp'; + case 'image/avif': + return 'avif'; + } + } + + final uri = sourceUrl == null ? null : Uri.tryParse(sourceUrl); + final segment = uri?.pathSegments.isNotEmpty == true + ? uri!.pathSegments.last + : ''; + final match = RegExp(r'\.(png|jpg|jpeg|webp|avif)$', caseSensitive: false) + .firstMatch(segment); + final extension = match?.group(1)?.toLowerCase(); + if (extension == 'jpeg') return 'jpg'; + return extension ?? 'tile'; + } + + String? _contentTypeFromPath(String relativePath) { + if (relativePath.endsWith('.png')) return 'image/png'; + if (relativePath.endsWith('.jpg') || relativePath.endsWith('.jpeg')) { + return 'image/jpeg'; + } + if (relativePath.endsWith('.webp')) return 'image/webp'; + if (relativePath.endsWith('.avif')) return 'image/avif'; + return null; + } + + Future?> _loadTileRow( + String styleHash, + int z, + int x, + int y, { + bool hydrate = true, + }) async { + var rows = await (await _db).query( + 'offline_tile_entries', + where: 'style_hash = ? AND z = ? AND x = ? AND y = ?', + whereArgs: [styleHash, z, x, y], + limit: 1, + ); + if (rows.isEmpty && hydrate) { + await _hydrateStyleFromDisk(styleHash); + rows = await (await _db).query( + 'offline_tile_entries', + where: 'style_hash = ? AND z = ? AND x = ? AND y = ?', + whereArgs: [styleHash, z, x, y], + limit: 1, + ); + } + return rows.isEmpty ? null : rows.first; + } +} diff --git a/lib/services/packet_capture_storage_service.dart b/lib/services/packet_capture_storage_service.dart new file mode 100644 index 0000000..61caab5 --- /dev/null +++ b/lib/services/packet_capture_storage_service.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import '../models/ble_packet_log.dart'; + +class StoredPacketCapture { + final DateTime timestamp; + final String direction; + final int? responseCode; + final String? description; + final String rawBase64; + final int rawSize; + final double? snrDb; + final int? rssiDbm; + + const StoredPacketCapture({ + required this.timestamp, + required this.direction, + required this.responseCode, + required this.description, + required this.rawBase64, + required this.rawSize, + required this.snrDb, + required this.rssiDbm, + }); + + Map toJson() { + return { + 'ts': timestamp.millisecondsSinceEpoch, + 'dir': direction, + 'code': responseCode, + 'desc': description, + 'raw': rawBase64, + 'size': rawSize, + 'snr': snrDb, + 'rssi': rssiDbm, + }; + } + + static StoredPacketCapture fromJson(Map json) { + return StoredPacketCapture( + timestamp: DateTime.fromMillisecondsSinceEpoch((json['ts'] as num).toInt()), + direction: (json['dir'] as String?) ?? 'rx', + responseCode: (json['code'] as num?)?.toInt(), + description: json['desc'] as String?, + rawBase64: (json['raw'] as String?) ?? '', + rawSize: (json['size'] as num?)?.toInt() ?? 0, + snrDb: (json['snr'] as num?)?.toDouble(), + rssiDbm: (json['rssi'] as num?)?.toInt(), + ); + } +} + +/// Durable storage for raw BLE packets so future features can consume +/// historical packet bytes across app restarts. +class PacketCaptureStorageService { + static const String _fileName = 'packet_captures.jsonl'; + static const int _maxStoredPackets = 20000; + + File? _file; + + Future _resolveFile() async { + if (kIsWeb) { + throw UnsupportedError('Packet capture file storage is not supported on web'); + } + if (_file != null) return _file!; + final dir = await getApplicationSupportDirectory(); + final file = File('${dir.path}/$_fileName'); + if (!await file.exists()) { + await file.create(recursive: true); + } + _file = file; + return file; + } + + Future appendLogs(List logs) async { + if (logs.isEmpty) return; + if (kIsWeb) return; + try { + final file = await _resolveFile(); + final sink = file.openWrite(mode: FileMode.append); + for (final log in logs) { + final row = StoredPacketCapture( + timestamp: log.timestamp, + direction: log.direction.name, + responseCode: log.responseCode, + description: log.description, + rawBase64: base64Encode(log.rawData), + rawSize: log.rawData.length, + snrDb: log.logRxDataInfo?.snrDb, + rssiDbm: log.logRxDataInfo?.rssiDbm, + ); + sink.writeln(jsonEncode(row.toJson())); + } + await sink.flush(); + await sink.close(); + await _pruneIfNeeded(file); + } catch (e) { + debugPrint('❌ [PacketCaptureStorage] Failed to append logs: $e'); + } + } + + Future> loadRecent({int limit = 500}) async { + if (kIsWeb) return const []; + try { + final file = await _resolveFile(); + if (!await file.exists()) return const []; + final lines = await file.readAsLines(); + if (lines.isEmpty) return const []; + final start = lines.length > limit ? lines.length - limit : 0; + return lines + .sublist(start) + .where((l) => l.trim().isNotEmpty) + .map((l) => StoredPacketCapture.fromJson(jsonDecode(l) as Map)) + .toList(); + } catch (e) { + debugPrint('❌ [PacketCaptureStorage] Failed to load recent logs: $e'); + return const []; + } + } + + Future count() async { + if (kIsWeb) return 0; + try { + final file = await _resolveFile(); + if (!await file.exists()) return 0; + final lines = await file.readAsLines(); + return lines.where((l) => l.trim().isNotEmpty).length; + } catch (_) { + return 0; + } + } + + Future clear() async { + if (kIsWeb) return; + try { + final file = await _resolveFile(); + if (await file.exists()) { + await file.writeAsString(''); + } + } catch (e) { + debugPrint('❌ [PacketCaptureStorage] Failed to clear logs: $e'); + } + } + + Future _pruneIfNeeded(File file) async { + final lines = await file.readAsLines(); + if (lines.length <= _maxStoredPackets) return; + final keep = lines.sublist(lines.length - _maxStoredPackets); + await file.writeAsString('${keep.join('\n')}\n'); + } +} diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart new file mode 100644 index 0000000..3263cbf --- /dev/null +++ b/lib/services/path_history_service.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/contact.dart'; +import '../models/path_selection.dart'; + +class _ManualPathSelectionRecord { + final List pathBytes; + final int hopCount; + final int hashSize; + + const _ManualPathSelectionRecord({ + required this.pathBytes, + required this.hopCount, + required this.hashSize, + }); + + factory _ManualPathSelectionRecord.fromJson(Map json) { + final pathBytes = (json['pathBytes'] as List? ?? const []) + .whereType() + .toList(); + return _ManualPathSelectionRecord( + pathBytes: pathBytes, + hopCount: json['hopCount'] as int? ?? 0, + hashSize: json['hashSize'] as int? ?? 1, + ); + } + + Map 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 _legacyStorageKey = 'contact_path_history_v2'; + static const String _manualRouteStorageKey = + 'contact_manual_path_overrides_v1'; + + final Map _manualSelections = {}; + bool _isLoaded = false; + + Future initialize() async { + if (_isLoaded) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_legacyStorageKey); + + final manualRaw = prefs.getString(_manualRouteStorageKey); + try { + if (manualRaw != null && manualRaw.isNotEmpty) { + final decoded = jsonDecode(manualRaw); + if (decoded is Map) { + for (final entry in decoded.entries) { + final value = entry.value; + if (value is Map) { + _manualSelections[entry.key] = + _ManualPathSelectionRecord.fromJson(value); + } + } + } + } + } catch (error) { + debugPrint( + '⚠️ [PathHistoryService] Failed to load manual routes: $error', + ); + } + + _isLoaded = true; + } + + Future getSelectionForContact(Contact contact) async { + await initialize(); + final manualSelection = _manualSelections[contact.publicKeyHex]; + if (manualSelection != null) { + return manualSelection.toSelection(); + } + + final route = ContactRouteCodec.fromContact(contact); + if (route == null) { + return PathSelection.flood(); + } + + return PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList(route.pathBytes), + hopCount: route.hopCount, + hashSize: route.hashSize, + ); + } + + Future setManualRouteForContact( + Contact contact, + ParsedContactRoute route, + ) async { + await setManualSelectionFor( + contact.publicKeyHex, + PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList(route.pathBytes), + hopCount: route.hopCount, + hashSize: route.hashSize, + ), + ); + } + + Future setManualSelectionFor( + String contactPublicKeyHex, + PathSelection selection, + ) async { + await initialize(); + _manualSelections[contactPublicKeyHex] = _ManualPathSelectionRecord( + pathBytes: selection.pathBytes.toList(), + hopCount: selection.hopCount, + hashSize: selection.hashSize, + ); + await _persistManualSelections(); + } + + Future getManualSelectionForContact(Contact contact) async { + await initialize(); + return _manualSelections[contact.publicKeyHex]?.toSelection(); + } + + Future clearManualRouteFor(String contactPublicKeyHex) async { + await initialize(); + _manualSelections.remove(contactPublicKeyHex); + await _persistManualSelections(); + } + + Future _persistManualSelections() async { + final prefs = await SharedPreferences.getInstance(); + final manualPayload = {}; + for (final entry in _manualSelections.entries) { + manualPayload[entry.key] = entry.value.toJson(); + } + await prefs.setString(_manualRouteStorageKey, jsonEncode(manualPayload)); + } +} diff --git a/lib/services/profile_device_key_resolver.dart b/lib/services/profile_device_key_resolver.dart new file mode 100644 index 0000000..b058ac5 --- /dev/null +++ b/lib/services/profile_device_key_resolver.dart @@ -0,0 +1,21 @@ +import '../models/device_info.dart'; + +class ProfileDeviceKeyResolver { + static String? resolve({ + required DeviceInfo deviceInfo, + required ConnectionMode connectionMode, + }) { + // Always prefer the public key — it's the only truly stable identifier. + // Don't fall back to deviceId to avoid creating duplicate profiles when + // publicKey arrives late (after initial connection but before DeviceInfo). + final publicKey = deviceInfo.publicKey; + if (publicKey == null || publicKey.isEmpty) { + return null; // Wait until publicKey is available + } + + final hex = publicKey + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return hex.isNotEmpty ? 'pk:$hex' : null; + } +} diff --git a/lib/services/profile_manager.dart b/lib/services/profile_manager.dart new file mode 100644 index 0000000..099bc4d --- /dev/null +++ b/lib/services/profile_manager.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/config_profile.dart'; +import 'profiles_feature_service.dart'; + +class ProfileManager with ChangeNotifier { + static const String _profilesKey = 'profiles_library'; + static const String activeProfileIdKey = 'profiles_active_profile_id'; + static const String _deviceProfileDefaultsKey = + 'profiles_device_active_profile_ids'; + static const String _transferHistoryKey = 'profiles_transfer_history'; + + final List _customProfiles = []; + final List _transferHistory = + []; + final Map _deviceProfileDefaults = {}; + bool _isInitialized = false; + bool _profilesEnabled = false; + String _activeProfileId = ConfigProfile.defaultProfileId; + + bool get isInitialized => _isInitialized; + bool get profilesEnabled => _profilesEnabled; + String get activeProfileId => _activeProfileId; + List get customProfiles => List.unmodifiable(_customProfiles); + List get transferHistory => + List.unmodifiable(_transferHistory); + + List get visibleProfiles => [ + if (_profilesEnabled) ConfigProfile.defaultProfile(), + ..._customProfiles, + ]; + + Future initialize() async { + if (_isInitialized) return; + final prefs = await SharedPreferences.getInstance(); + _profilesEnabled = await ProfilesFeatureService.isEnabled(); + _activeProfileId = + prefs.getString(activeProfileIdKey) ?? ConfigProfile.defaultProfileId; + + final deviceDefaultsJson = prefs.getString(_deviceProfileDefaultsKey); + if (deviceDefaultsJson != null && deviceDefaultsJson.isNotEmpty) { + final decoded = jsonDecode(deviceDefaultsJson); + if (decoded is Map) { + _deviceProfileDefaults + ..clear() + ..addAll( + decoded.map((key, value) => MapEntry(key, value?.toString() ?? '')) + ..removeWhere((key, value) => value.isEmpty), + ); + } + } + + final profilesJson = prefs.getString(_profilesKey); + if (profilesJson != null && profilesJson.isNotEmpty) { + final decoded = jsonDecode(profilesJson) as List; + _customProfiles + ..clear() + ..addAll( + decoded.whereType>().map(ConfigProfile.fromJson), + ); + } + + final historyJson = prefs.getString(_transferHistoryKey); + if (historyJson != null && historyJson.isNotEmpty) { + final decoded = jsonDecode(historyJson) as List; + _transferHistory + ..clear() + ..addAll( + decoded.whereType>().map( + ProfileTransferRecord.fromJson, + ), + ); + } + + _isInitialized = true; + ProfileStorageScope.setScope( + profilesEnabled: _profilesEnabled, + activeProfileId: _activeProfileId, + ); + notifyListeners(); + } + + ConfigProfile? getProfile(String id) { + if (id == ConfigProfile.defaultProfileId) { + return ConfigProfile.defaultProfile(); + } + for (final profile in _customProfiles) { + if (profile.id == id) { + return profile; + } + } + return null; + } + + Future setProfilesEnabled(bool enabled) async { + _profilesEnabled = enabled; + await ProfilesFeatureService.setEnabled(enabled); + ProfileStorageScope.setScope( + profilesEnabled: _profilesEnabled, + activeProfileId: _activeProfileId, + ); + notifyListeners(); + } + + Future setActiveProfileId(String id) async { + await setActiveProfileIdForDevice(id); + } + + String profileIdForDevice(String? deviceKey) { + if (deviceKey == null || deviceKey.isEmpty) { + return _activeProfileId; + } + return _deviceProfileDefaults[deviceKey] ?? ConfigProfile.defaultProfileId; + } + + bool hasProfileForDevice(String? deviceKey) { + if (deviceKey == null || deviceKey.isEmpty) { + return false; + } + return _deviceProfileDefaults.containsKey(deviceKey); + } + + Future setActiveProfileIdForDevice( + String id, { + String? deviceKey, + }) async { + _activeProfileId = id; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(activeProfileIdKey, id); + if (deviceKey != null && deviceKey.isNotEmpty) { + _deviceProfileDefaults[deviceKey] = id; + await prefs.setString( + _deviceProfileDefaultsKey, + jsonEncode(_deviceProfileDefaults), + ); + } + ProfileStorageScope.setScope( + profilesEnabled: _profilesEnabled, + activeProfileId: _activeProfileId, + ); + notifyListeners(); + } + + Future upsertProfile(ConfigProfile profile) async { + final index = _customProfiles.indexWhere((item) => item.id == profile.id); + if (index == -1) { + _customProfiles.add(profile); + } else { + _customProfiles[index] = profile; + } + _customProfiles.sort((a, b) => a.name.compareTo(b.name)); + await _persistProfiles(); + notifyListeners(); + } + + Future deleteProfile(String id) async { + _customProfiles.removeWhere((profile) => profile.id == id); + await _persistProfiles(); + notifyListeners(); + } + + Future recordTransfer(ProfileTransferRecord record) async { + _transferHistory.insert(0, record); + if (_transferHistory.length > 100) { + _transferHistory.removeRange(100, _transferHistory.length); + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _transferHistoryKey, + jsonEncode(_transferHistory.map((item) => item.toJson()).toList()), + ); + notifyListeners(); + } + + Future _persistProfiles() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _profilesKey, + jsonEncode(_customProfiles.map((profile) => profile.toJson()).toList()), + ); + } +} diff --git a/lib/services/profile_transport.dart b/lib/services/profile_transport.dart new file mode 100644 index 0000000..64e43f6 --- /dev/null +++ b/lib/services/profile_transport.dart @@ -0,0 +1,10 @@ +abstract class ProfileTransport { + String get label; +} + +class FileProfileTransport implements ProfileTransport { + const FileProfileTransport(); + + @override + String get label => 'File'; +} diff --git a/lib/services/profile_workspace_coordinator.dart b/lib/services/profile_workspace_coordinator.dart new file mode 100644 index 0000000..ff240d6 --- /dev/null +++ b/lib/services/profile_workspace_coordinator.dart @@ -0,0 +1,499 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../models/config_profile.dart'; +import '../providers/app_provider.dart'; +import '../providers/channels_provider.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +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'; +import 'message_storage_service.dart'; +import 'profile_manager.dart'; +import 'profile_device_key_resolver.dart'; +import 'profiles_feature_service.dart'; +import 'map_workspace_snapshot_service.dart'; + +class ProfileWorkspaceCoordinator { + ProfileWorkspaceCoordinator({ + required this.profileManager, + required this.connectionProvider, + required this.contactsProvider, + required this.messagesProvider, + required this.sensorsProvider, + required this.mapProvider, + required this.drawingProvider, + required this.channelsProvider, + required this.voiceProvider, + required this.imageProvider, + required this.appProvider, + AppConfigSnapshotService? appConfigSnapshotService, + MapWorkspaceSnapshotService? mapWorkspaceSnapshotService, + DeviceConfigApplicator? deviceConfigApplicator, + MessageStorageService? messageStorageService, + ContactStorageService? contactStorageService, + }) : _appConfigSnapshotService = + appConfigSnapshotService ?? AppConfigSnapshotService(), + _mapWorkspaceSnapshotService = + mapWorkspaceSnapshotService ?? MapWorkspaceSnapshotService(), + _deviceConfigApplicator = + deviceConfigApplicator ?? DeviceConfigApplicator(), + _messageStorageService = + messageStorageService ?? MessageStorageService(), + _contactStorageService = + contactStorageService ?? ContactStorageService(); + + final ProfileManager profileManager; + final ConnectionProvider connectionProvider; + final ContactsProvider contactsProvider; + final MessagesProvider messagesProvider; + final SensorsProvider sensorsProvider; + 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; + Future? _deviceProfileSyncFuture; + + Future setProfilesEnabled(bool enabled) async { + final wasEnabled = profileManager.profilesEnabled; + if (wasEnabled && !enabled) { + await _saveActiveCustomProfileSnapshot(); + } else { + await _persistCurrentState(); + } + await profileManager.setProfilesEnabled(enabled); + ProfileStorageScope.setScope( + profilesEnabled: enabled, + activeProfileId: enabled ? profileManager.activeProfileId : 'default', + ); + if (enabled) { + await _ensureProfileForCurrentDevice(); + if (wasEnabled) { + await openProfile(profileManager.activeProfileId); + } else { + await _switchRuntimeScope(profileManager.activeProfileId); + final profile = await resolveProfile(profileManager.activeProfileId); + await _appConfigSnapshotService.apply( + profile.sections.appSettings, + appProvider, + ); + await _mapWorkspaceSnapshotService.apply( + profile.sections.mapWorkspace, + mapProvider: mapProvider, + drawingProvider: drawingProvider, + ); + } + } else { + await _switchRuntimeScope('default'); + } + } + + Future snapshotCurrentProfile({ + required String id, + required String name, + String? notes, + }) async { + final deviceSections = _deviceConfigApplicator.capture( + connectionProvider: connectionProvider, + channelsProvider: channelsProvider, + ); + final appSettings = await _appConfigSnapshotService.capture(appProvider); + final mapWorkspace = await _mapWorkspaceSnapshotService.capture( + mapProvider: mapProvider, + drawingProvider: drawingProvider, + ); + final now = DateTime.now(); + return ConfigProfile( + id: id, + name: name, + createdAt: now, + updatedAt: now, + notes: notes, + sections: ConfigProfileSections( + deviceConfig: deviceSections.deviceConfig, + channels: deviceSections.channels, + appSettings: appSettings, + mapWorkspace: mapWorkspace, + ), + ); + } + + Future createProfileFromCurrent({ + required String name, + String? notes, + }) async { + final profileId = 'profile_${DateTime.now().millisecondsSinceEpoch}'; + final profile = await snapshotCurrentProfile( + id: profileId, + name: name, + notes: notes, + ); + await _initializeEmptyStorageNamespace(profileId); + await profileManager.upsertProfile(profile); + return profile; + } + + Future duplicateProfile(ConfigProfile source) async { + final duplicate = await snapshotCurrentProfile( + id: 'profile_${DateTime.now().millisecondsSinceEpoch}', + name: '${source.name} Copy', + notes: source.notes, + ); + final sourceNamespace = source.id == ConfigProfile.defaultProfileId + ? null + : source.id; + await _copyStorageNamespace(sourceNamespace, duplicate.id); + await profileManager.upsertProfile( + duplicate.copyWith( + sections: source.id == profileManager.activeProfileId + ? duplicate.sections + : source.sections, + ), + ); + return duplicate; + } + + Future renameProfile(ConfigProfile profile, String name) async { + await profileManager.upsertProfile( + profile.copyWith(name: name, updatedAt: DateTime.now()), + ); + } + + Future deleteProfile(ConfigProfile profile) async { + if (profile.isDefault) return; + if (profile.id == profileManager.activeProfileId) { + await openProfile(ConfigProfile.defaultProfileId); + } + await _messageStorageService.clearMessages(namespace: profile.id); + await _contactStorageService.clearContacts(namespace: profile.id); + await _contactStorageService.clearContactGroups(namespace: profile.id); + await _contactStorageService.clearPendingAdverts(namespace: profile.id); + await profileManager.deleteProfile(profile.id); + } + + Future openProfile(String profileId) async { + final deviceKey = _currentDeviceProfileKey; + await _saveActiveCustomProfileSnapshot(); + await connectionProvider.disconnect(); + await profileManager.setActiveProfileIdForDevice( + profileId, + deviceKey: deviceKey, + ); + await _switchRuntimeScope(profileId); + + final profile = await resolveProfile(profileId); + await _appConfigSnapshotService.apply( + profile.sections.appSettings, + appProvider, + ); + await _mapWorkspaceSnapshotService.apply( + profile.sections.mapWorkspace, + mapProvider: mapProvider, + drawingProvider: drawingProvider, + ); + } + + Future applyProfile(String profileId) async { + await openProfile(profileId); + final profile = await resolveProfile(profileId); + await _deviceConfigApplicator.apply( + profile, + connectionProvider: connectionProvider, + channelsProvider: channelsProvider, + ); + } + + Future resolveProfile(String profileId) async { + if (profileId == ConfigProfile.defaultProfileId) { + return snapshotCurrentProfile(id: profileId, name: 'Default'); + } + return profileManager.getProfile(profileId) ?? + await snapshotCurrentProfile(id: profileId, name: 'Unknown'); + } + + Future exportProfile(ConfigProfile profile) async { + final resolved = profile.id == ConfigProfile.defaultProfileId + ? await snapshotCurrentProfile(id: profile.id, name: profile.name) + : profile; + final tempDir = await getTemporaryDirectory(); + final file = File( + '${tempDir.path}/${resolved.name.replaceAll(' ', '_').toLowerCase()}_${resolved.id}.meshcore_profile.json', + ); + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(resolved.toJson()), + ); + await SharePlus.instance.share(ShareParams(files: [XFile(file.path)])); + await profileManager.recordTransfer( + ProfileTransferRecord( + profileId: resolved.id, + direction: 'export', + timestamp: DateTime.now(), + detail: 'Shared as file', + ), + ); + } + + Future importProfileFromFile() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + withData: true, + ); + if (result == null || result.files.isEmpty) { + return null; + } + final file = result.files.first; + if (file.bytes == null) return null; + + final decoded = jsonDecode(utf8.decode(file.bytes!)); + if (decoded is! Map) { + return null; + } + + final imported = ConfigProfile.fromJson(decoded).copyWith( + id: 'profile_${DateTime.now().millisecondsSinceEpoch}', + updatedAt: DateTime.now(), + ); + await profileManager.upsertProfile(imported); + await profileManager.recordTransfer( + ProfileTransferRecord( + profileId: imported.id, + direction: 'import', + timestamp: DateTime.now(), + detail: file.name, + ), + ); + return imported; + } + + Future syncActiveProfileForCurrentDevice() async { + 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 _syncActiveProfileForCurrentDeviceInternal() async { + final deviceKey = _currentDeviceProfileKey; + if (deviceKey == null) { + return; + } + + 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 _ensureProfileForCurrentDevice() async { + final deviceKey = _currentDeviceProfileKey; + final targetProfileId = profileManager.profileIdForDevice(deviceKey); + final existingProfile = profileManager.getProfile(targetProfileId); + if (profileManager.hasProfileForDevice(deviceKey) && + existingProfile != null) { + return existingProfile; + } + if (profileManager.hasProfileForDevice(deviceKey) && + targetProfileId == ConfigProfile.defaultProfileId) { + return ConfigProfile.defaultProfile(); + } + if (deviceKey == null) { + return await resolveProfile(profileManager.activeProfileId); + } + + final profile = await createProfileFromCurrent( + name: _buildDeviceProfileName(), + ); + await profileManager.setActiveProfileIdForDevice( + profile.id, + deviceKey: deviceKey, + ); + return profile; + } + + String _buildDeviceProfileName() { + final deviceInfo = connectionProvider.deviceInfo; + final name = deviceInfo.selfName?.trim(); + if (name != null && name.isNotEmpty) { + return 'Device ${_sanitizeProfileLabel(name)}'; + } + + final displayName = deviceInfo.displayName?.trim(); + if (displayName != null && displayName.isNotEmpty) { + return 'Device ${_sanitizeProfileLabel(displayName)}'; + } + + final deviceId = deviceInfo.deviceId?.trim(); + if (deviceId != null && deviceId.isNotEmpty) { + return 'Device ${_sanitizeProfileLabel(deviceId)}'; + } + + return 'Device Profile'; + } + + String _sanitizeProfileLabel(String value) { + return value.replaceAll(RegExp(r'\s+'), ' ').trim(); + } + + String? get _currentDeviceProfileKey => ProfileDeviceKeyResolver.resolve( + deviceInfo: connectionProvider.deviceInfo, + connectionMode: connectionProvider.connectionMode, + ); + + Future _switchRuntimeScope(String profileId) async { + final runtimeProfilesEnabled = profileManager.profilesEnabled; + ProfileStorageScope.setScope( + profilesEnabled: runtimeProfilesEnabled, + activeProfileId: profileId, + ); + await messagesProvider.reloadFromStorage( + namespace: ProfileStorageScope.effectiveNamespace, + ); + await contactsProvider.reloadFromStorage( + namespace: ProfileStorageScope.effectiveNamespace, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + await sensorsProvider.reloadProfileScopedState(); + await drawingProvider.reloadProfileScopedState(); + await mapProvider.reloadProfileScopedState(); + await voiceProvider.reloadProfileScopedState(); + await imageProvider.reloadProfileScopedState(); + await appProvider.reloadProfileScopedSettings(); + } + + Future _persistCurrentState() async { + await messagesProvider.persistNow(); + await contactsProvider.persistNow(); + } + + Future _saveActiveCustomProfileSnapshot() async { + if (profileManager.activeProfileId == ConfigProfile.defaultProfileId) { + await _persistCurrentState(); + return; + } + final current = profileManager.getProfile(profileManager.activeProfileId); + if (current == null) { + await _persistCurrentState(); + return; + } + final snapshot = await snapshotCurrentProfile( + id: current.id, + name: current.name, + notes: current.notes, + ); + await profileManager.upsertProfile(snapshot); + await _persistCurrentState(); + } + + Future _copyStorageNamespace( + String? sourceNamespace, + String targetNamespace, + ) async { + final messages = await _messageStorageService.loadMessages( + namespace: sourceNamespace, + ); + final contactLocations = await _messageStorageService + .loadMessageContactLocations(namespace: sourceNamespace); + final receptionDetails = await _messageStorageService + .loadMessageReceptionDetails(namespace: sourceNamespace); + final transferDetails = await _messageStorageService + .loadMessageTransferDetails(namespace: sourceNamespace); + final routeMetadata = await _messageStorageService.loadMessageRouteMetadata( + namespace: sourceNamespace, + ); + final removedIds = await _messageStorageService.loadRemovedSarMarkerIds( + namespace: sourceNamespace, + ); + await _messageStorageService.saveMessages( + messages, + messageContactLocations: contactLocations, + messageReceptionDetails: receptionDetails, + messageTransferDetails: transferDetails, + messageRouteMetadata: routeMetadata, + namespace: targetNamespace, + ); + await _messageStorageService.saveRemovedSarMarkerIds( + removedIds, + namespace: targetNamespace, + ); + + final contacts = await _contactStorageService.loadContacts( + namespace: sourceNamespace, + ); + final groups = await _contactStorageService.loadContactGroups( + namespace: sourceNamespace, + ); + final pending = await _contactStorageService.loadPendingAdverts( + namespace: sourceNamespace, + ); + await _contactStorageService.saveContacts( + contacts, + namespace: targetNamespace, + ); + await _contactStorageService.saveContactGroups( + groups, + namespace: targetNamespace, + ); + await _contactStorageService.savePendingAdverts( + pending, + namespace: targetNamespace, + ); + } + + Future _initializeEmptyStorageNamespace(String namespace) async { + await _messageStorageService.clearMessages(namespace: namespace); + await _contactStorageService.clearContacts(namespace: namespace); + await _contactStorageService.clearContactGroups(namespace: namespace); + await _contactStorageService.clearPendingAdverts(namespace: namespace); + } +} diff --git a/lib/services/profiles_feature_service.dart b/lib/services/profiles_feature_service.dart new file mode 100644 index 0000000..d0d44f9 --- /dev/null +++ b/lib/services/profiles_feature_service.dart @@ -0,0 +1,54 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class ProfilesFeatureService { + static const String enabledKey = 'profiles_enabled'; + + static Future isEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(enabledKey) ?? true; + } + + static Future setEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(enabledKey, enabled); + } +} + +class ProfileStorageScope { + static bool _profilesEnabled = true; + static String _activeProfileId = 'default'; + + static Future bootstrap({ + required bool profilesEnabled, + required String activeProfileId, + }) async { + _profilesEnabled = profilesEnabled; + _activeProfileId = activeProfileId; + } + + static void setScope({ + required bool profilesEnabled, + required String activeProfileId, + }) { + _profilesEnabled = profilesEnabled; + _activeProfileId = activeProfileId; + } + + static bool get profilesEnabled => _profilesEnabled; + static String get activeProfileId => _activeProfileId; + + static String? get effectiveNamespace { + if (!_profilesEnabled || _activeProfileId == 'default') { + return null; + } + return _activeProfileId; + } + + static String scopedKey(String baseKey, {String? namespace}) { + final scope = namespace ?? effectiveNamespace; + if (scope == null || scope.isEmpty) { + return baseKey; + } + return 'profile.$scope.$baseKey'; + } +} diff --git a/lib/services/recent_tcp_connections_service.dart b/lib/services/recent_tcp_connections_service.dart new file mode 100644 index 0000000..4d296ec --- /dev/null +++ b/lib/services/recent_tcp_connections_service.dart @@ -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 toJson() => { + 'name': name, + 'host': host, + 'port': port, + 'lastUsedAt': lastUsedAt.toIso8601String(), + }; + + factory RecentTcpConnection.fromJson(Map 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> load() async { + final prefs = await SharedPreferences.getInstance(); + final storedEntries = prefs.getStringList(_prefsKey) ?? const []; + + final connections = []; + for (final entry in storedEntries) { + try { + final decoded = jsonDecode(entry); + if (decoded is! Map) { + 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> 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( + 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; + } +} diff --git a/lib/services/region_scope_preferences.dart b/lib/services/region_scope_preferences.dart new file mode 100644 index 0000000..0d4c6f6 --- /dev/null +++ b/lib/services/region_scope_preferences.dart @@ -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 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 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 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)); + } +} diff --git a/lib/services/relay_candidate_sorter.dart b/lib/services/relay_candidate_sorter.dart new file mode 100644 index 0000000..ba5d856 --- /dev/null +++ b/lib/services/relay_candidate_sorter.dart @@ -0,0 +1,48 @@ +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; + +import '../models/contact.dart'; + +class RelayCandidateSorter { + const RelayCandidateSorter(); + + List sortByDistanceFromSelf( + List contacts, { + required LatLng? selfPoint, + }) { + final sorted = List.from(contacts); + sorted.sort((a, b) { + if (selfPoint != null) { + final distanceCompare = _distanceFrom( + selfPoint, + a, + ).compareTo(_distanceFrom(selfPoint, b)); + if (distanceCompare != 0) { + return distanceCompare; + } + } + + final nameCompare = a.displayName.compareTo(b.displayName); + if (nameCompare != 0) { + return nameCompare; + } + + return a.publicKeyHex.compareTo(b.publicKeyHex); + }); + return sorted; + } + + double _distanceFrom(LatLng selfPoint, Contact contact) { + final location = contact.displayLocation; + if (location == null) { + return double.infinity; + } + + return Geolocator.distanceBetween( + selfPoint.latitude, + selfPoint.longitude, + location.latitude, + location.longitude, + ); + } +} diff --git a/lib/services/route_hash_preferences.dart b/lib/services/route_hash_preferences.dart new file mode 100644 index 0000000..0a92afc --- /dev/null +++ b/lib/services/route_hash_preferences.dart @@ -0,0 +1,34 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'profiles_feature_service.dart'; + +class RouteHashPreferences { + static const String _hashSizeKey = 'route_hash_size'; + static const int defaultHashSize = 1; + + static Future getHashSize() async { + final prefs = await SharedPreferences.getInstance(); + final value = + prefs.getInt(ProfileStorageScope.scopedKey(_hashSizeKey)) ?? + defaultHashSize; + return _normalize(value); + } + + static Future setHashSize(int value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt( + ProfileStorageScope.scopedKey(_hashSizeKey), + _normalize(value), + ); + } + + static int normalizeSync(int value) => _normalize(value); + + /// Valid hash sizes per the MeshCore protocol. + /// The path encoding uses 2 bits: 00=1B, 01=2B, 10=3B, 11=4B. + static const List supportedSizes = [1, 2, 3]; + + static int _normalize(int value) { + if (supportedSizes.contains(value)) return value; + return defaultHashSize; + } +} diff --git a/lib/services/sar_template_service.dart b/lib/services/sar_template_service.dart new file mode 100644 index 0000000..e0d2126 --- /dev/null +++ b/lib/services/sar_template_service.dart @@ -0,0 +1,244 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/sar_template.dart'; +import '../utils/sar_message_parser.dart'; + +/// SAR Template Service - Manages SAR templates with persistence +class SarTemplateService extends ChangeNotifier { + static final SarTemplateService _instance = SarTemplateService._internal(); + factory SarTemplateService() => _instance; + SarTemplateService._internal(); + + static const String _storageKey = 'sar_templates'; + List _templates = []; + bool _initialized = false; + + /// Get all templates + List get templates => List.unmodifiable(_templates); + + /// Get default templates + List get defaultTemplates => + _templates.where((t) => t.isDefault).toList(); + + /// Get custom templates + List get customTemplates => + _templates.where((t) => !t.isDefault).toList(); + + /// Check if initialized + bool get isInitialized => _initialized; + + /// Initialize service and load templates + Future initialize() async { + if (_initialized) return; + + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_storageKey); + + if (jsonString != null && jsonString.isNotEmpty) { + // Load saved templates + final List jsonList = json.decode(jsonString); + _templates = jsonList.map((json) => SarTemplate.fromJson(json)).toList(); + + // Ensure defaults exist (in case user deleted them or version upgrade) + _ensureDefaultTemplates(); + } else { + // First time - initialize with defaults + _templates = SarTemplate.defaults; + await _saveToStorage(); + } + + _initialized = true; + notifyListeners(); + debugPrint('SarTemplateService initialized with ${_templates.length} templates'); + } catch (e) { + debugPrint('Error initializing SAR templates: $e'); + // Fallback to defaults on error + _templates = SarTemplate.defaults; + _initialized = true; + notifyListeners(); + } + } + + /// Ensure default templates exist + void _ensureDefaultTemplates() { + final defaults = SarTemplate.defaults; + final existingDefaultIds = _templates.where((t) => t.isDefault).map((t) => t.id).toSet(); + + // Add missing defaults + for (final defaultTemplate in defaults) { + if (!existingDefaultIds.contains(defaultTemplate.id)) { + _templates.insert(0, defaultTemplate); + } + } + } + + /// Save templates to storage + Future _saveToStorage() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonList = _templates.map((t) => t.toJson()).toList(); + final jsonString = json.encode(jsonList); + await prefs.setString(_storageKey, jsonString); + debugPrint('Saved ${_templates.length} SAR templates to storage'); + } catch (e) { + debugPrint('Error saving SAR templates: $e'); + rethrow; + } + } + + /// Add new template + Future addTemplate(SarTemplate template) async { + _templates.add(template); + await _saveToStorage(); + notifyListeners(); + debugPrint('Added SAR template: ${template.name}'); + } + + /// Update existing template + Future updateTemplate(String id, SarTemplate updatedTemplate) async { + final index = _templates.indexWhere((t) => t.id == id); + if (index != -1) { + _templates[index] = updatedTemplate; + await _saveToStorage(); + notifyListeners(); + debugPrint('Updated SAR template: ${updatedTemplate.name}'); + } else { + throw Exception('Template with id $id not found'); + } + } + + /// Delete template + Future deleteTemplate(String id) async { + final template = _templates.firstWhere((t) => t.id == id); + _templates.removeWhere((t) => t.id == id); + await _saveToStorage(); + notifyListeners(); + debugPrint('Deleted SAR template: ${template.name}'); + } + + /// Get template by ID + SarTemplate? getTemplateById(String id) { + try { + return _templates.firstWhere((t) => t.id == id); + } catch (e) { + return null; + } + } + + /// Import templates from clipboard + /// Expects SAR message format (one per line): + /// S:🧑:0,0:Person found + /// S:🔥:0,0:Active fire + Future importFromClipboard() async { + try { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + if (clipboardData == null || clipboardData.text == null || clipboardData.text!.trim().isEmpty) { + throw Exception('Clipboard is empty'); + } + + return importFromText(clipboardData.text!); + } catch (e) { + debugPrint('Error importing from clipboard: $e'); + rethrow; + } + } + + /// Import templates from text (SAR message format) + Future importFromText(String text) async { + try { + final lines = text.split('\n').where((line) => line.trim().isNotEmpty).toList(); + int importedCount = 0; + final List errors = []; + + for (final line in lines) { + final trimmed = line.trim(); + if (!trimmed.startsWith('S:')) { + errors.add('Invalid format: $trimmed'); + continue; + } + + // Validate with parser + if (!SarMessageParser.isValidFormat(trimmed)) { + final error = SarMessageParser.getFormatError(trimmed); + errors.add(error ?? 'Invalid SAR message format'); + continue; + } + + try { + final template = SarTemplate.fromSarMessage(trimmed); + + // Check for duplicates (same emoji + name) + final isDuplicate = _templates.any((t) => + t.emoji == template.emoji && t.name == template.name + ); + + if (!isDuplicate) { + _templates.add(template); + importedCount++; + } + } catch (e) { + errors.add('Error parsing line: $trimmed - $e'); + } + } + + if (importedCount > 0) { + await _saveToStorage(); + notifyListeners(); + } + + if (errors.isNotEmpty) { + debugPrint('Import errors: ${errors.join(', ')}'); + } + + debugPrint('Imported $importedCount SAR templates'); + return importedCount; + } catch (e) { + debugPrint('Error importing templates: $e'); + rethrow; + } + } + + /// Export all templates to clipboard (SAR message format) + Future exportToClipboard() async { + try { + final sarMessages = _templates.map((t) => t.toSarMessage()).join('\n'); + await Clipboard.setData(ClipboardData(text: sarMessages)); + debugPrint('Exported ${_templates.length} templates to clipboard'); + } catch (e) { + debugPrint('Error exporting to clipboard: $e'); + rethrow; + } + } + + /// Export templates to text (SAR message format) + String exportToText() { + return _templates.map((t) => t.toSarMessage()).join('\n'); + } + + /// Reset to default templates + Future resetToDefaults() async { + _templates = SarTemplate.defaults; + await _saveToStorage(); + notifyListeners(); + debugPrint('Reset to default SAR templates'); + } + + /// Clear all templates (including defaults) + Future clearAll() async { + _templates.clear(); + await _saveToStorage(); + notifyListeners(); + debugPrint('Cleared all SAR templates'); + } + + /// Get count of templates + int get templateCount => _templates.length; + + /// Check if template exists + bool hasTemplate(String id) { + return _templates.any((t) => t.id == id); + } +} diff --git a/lib/services/serial/serial_transport.dart b/lib/services/serial/serial_transport.dart new file mode 100644 index 0000000..75764b9 --- /dev/null +++ b/lib/services/serial/serial_transport.dart @@ -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 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> listDevices(); + Future requestDevice(); + Future connect(SerialDeviceInfo device); + void dispose(); +} + +SerialTransport createSerialTransport() => createSerialTransportImpl(); diff --git a/lib/services/serial/serial_transport_io.dart b/lib/services/serial/serial_transport_io.dart new file mode 100644 index 0000000..985dff3 --- /dev/null +++ b/lib/services/serial/serial_transport_io.dart @@ -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> 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 requestDevice() async => null; + + @override + Future 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 = [ + 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> _desktopSerialDevices() async { + final ports = []; + for (final address in SerialPort.availablePorts) { + final port = SerialPort(address); + try { + final title = _firstNonEmpty([ + port.productName, + port.description, + address, + ]); + final subtitleParts = [ + 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 _connectAndroid(SerialDeviceInfo device) async { + final usbDevice = device.handle as UsbDevice; + final service = MeshCoreSerialService(appName: 'MeshCore SAR'); + StreamSubscription? 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 _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? 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 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(); diff --git a/lib/services/serial/serial_transport_stub.dart b/lib/services/serial/serial_transport_stub.dart new file mode 100644 index 0000000..5c30f4d --- /dev/null +++ b/lib/services/serial/serial_transport_stub.dart @@ -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> listDevices() async => const []; + + @override + Future requestDevice() async => null; + + @override + Future connect(SerialDeviceInfo device) async { + throw UnsupportedError(unsupportedMessage); + } + + @override + void dispose() {} +} + +SerialTransport createSerialTransportImpl() => _UnsupportedSerialTransport(); diff --git a/lib/services/serial/serial_transport_web.dart b/lib/services/serial/serial_transport_web.dart new file mode 100644 index 0000000..cbff9a8 --- /dev/null +++ b/lib/services/serial/serial_transport_web.dart @@ -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> listDevices() async { + if (!isSupported) { + return const []; + } + final ports = await serial.getPorts().toDart; + return ports + .toDart + .whereType() + .map(_deviceInfoForPort) + .toList(growable: false); + } + + @override + Future requestDevice() async { + if (!isSupported) { + return null; + } + final port = await requestWebSerialPort(null); + if (port == null) { + return null; + } + return _deviceInfoForPort(port); + } + + @override + Future 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 _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(); diff --git a/lib/services/serial/usb_serial_transport.dart b/lib/services/serial/usb_serial_transport.dart new file mode 100644 index 0000000..d054ae5 --- /dev/null +++ b/lib/services/serial/usb_serial_transport.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:usb_serial/usb_serial.dart'; +import 'package:meshcore_client/meshcore_client.dart'; + +/// Android USB OTG transport for MeshCore serial connection. +/// +/// Wraps the `usb_serial` package to provide raw byte I/O to +/// [MeshCoreSerialService]. +class UsbSerialTransport { + static const int _baudRate = 115200; + + UsbPort? _port; + StreamSubscription? _inputSubscription; + final MeshCoreSerialService _service; + + UsbSerialTransport(this._service); + + MeshCoreSerialService get service => _service; + + bool get isConnected => _port != null; + + /// List available USB serial devices. + static Future> listDevices() async { + return UsbSerial.listDevices(); + } + + /// Connect to a USB serial device. + Future connect(UsbDevice device) async { + try { + _port = await device.create(); + if (_port == null) { + debugPrint('❌ [USB] Failed to create port for ${device.productName}'); + return false; + } + + final opened = await _port!.open(); + if (!opened) { + debugPrint('❌ [USB] Failed to open port'); + _port = null; + return false; + } + + await _port!.setDTR(true); + await _port!.setRTS(true); + await _port!.setPortParameters( + _baudRate, + UsbPort.DATABITS_8, + UsbPort.STOPBITS_1, + UsbPort.PARITY_NONE, + ); + + // Wire write callback + _service.writeRaw = _write; + + // Listen for incoming data + _inputSubscription = _port!.inputStream?.listen( + (data) => _service.feedRawBytes(data), + onError: (error) { + debugPrint('❌ [USB] Read error: $error'); + disconnect(); + }, + onDone: () { + debugPrint('⚠️ [USB] Port closed'); + disconnect(); + }, + ); + + debugPrint( + '✅ [USB] Connected to ${device.productName} at $_baudRate baud', + ); + + // Initialize MeshCore session + return _service.markConnected(); + } catch (e) { + debugPrint('❌ [USB] Connection failed: $e'); + await disconnect(); + return false; + } + } + + /// Disconnect from the USB device. + Future disconnect() async { + _service.markDisconnected(); + _service.writeRaw = null; + await _inputSubscription?.cancel(); + _inputSubscription = null; + await _port?.close(); + _port = null; + } + + Future _write(Uint8List data) async { + if (_port == null) throw Exception('USB not connected'); + await _port!.write(data); + } + + void dispose() { + disconnect(); + } +} diff --git a/lib/services/serial/web_serial_transport.dart b/lib/services/serial/web_serial_transport.dart new file mode 100644 index 0000000..11783f5 --- /dev/null +++ b/lib/services/serial/web_serial_transport.dart @@ -0,0 +1,20 @@ +import 'dart:async'; +import 'package:meshcore_client/meshcore_client.dart'; + +// Web Serial API is only available on web platform. +// This file provides the interface; the actual JS interop is conditional. + +/// Web Serial transport stub for non-web platforms. +/// On web, use [WebSerialTransportImpl] from web_serial_transport_web.dart. +class WebSerialTransport { + final MeshCoreSerialService service; + + WebSerialTransport(this.service); + + bool get isConnected => false; + bool get isSupported => false; + + Future requestAndConnect() async => false; + Future disconnect() async {} + void dispose() {} +} diff --git a/lib/services/tile_download_service.dart b/lib/services/tile_download_service.dart new file mode 100644 index 0000000..15d90d6 --- /dev/null +++ b/lib/services/tile_download_service.dart @@ -0,0 +1,342 @@ +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 downloadTiles({ + required List> polygons, + required int minZoom, + required int maxZoom, + required String urlTemplate, + String? displayName, + Map? headers, + int maxConcurrency = 6, + int rateLimit = 30, + }) { + final controller = StreamController(); + + _runDownload( + controller: controller, + polygons: polygons, + minZoom: minZoom, + maxZoom: maxZoom, + urlTemplate: urlTemplate, + displayName: displayName, + headers: headers, + maxConcurrency: maxConcurrency, + rateLimit: rateLimit, + ); + + return controller.stream; + } + + Future _runDownload({ + required StreamController controller, + required List> polygons, + required int minZoom, + required int maxZoom, + required String urlTemplate, + String? displayName, + Map? headers, + 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 = []; + 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 = >[]; + + 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, headers) + .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 _downloadSingleTile( + TileCoord tile, + String urlTemplate, + String styleHash, + Map? extraHeaders, + ) 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', ...?extraHeaders}, + ); + + if (response.statusCode != 200) { + if (attempt < maxRetries - 1) { + await Future.delayed(Duration(seconds: 1 << attempt)); + continue; + } + return TileFailed(tile, 'HTTP ${response.statusCode}'); + } + + await _cache.putTile( + styleHash, + tile.z, + tile.x, + tile.y, + response.bodyBytes, + contentType: response.headers['content-type'], + sourceUrl: url, + ); + + 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 = >[]; + + _Semaphore(this.maxCount); + + Future acquire() async { + if (_currentCount < maxCount) { + _currentCount++; + return; + } + final completer = Completer(); + _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 = []; + + _RateLimiter(this.maxPerSecond); + + Future 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()); + } +} diff --git a/lib/services/tile_math_service.dart b/lib/services/tile_math_service.dart new file mode 100644 index 0000000..20f6707 --- /dev/null +++ b/lib/services/tile_math_service.dart @@ -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 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 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 getTilesForPolygons( + List> polygons, + int minZoom, + int maxZoom, + ) { + final tileSet = {}; + + 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> 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; + } +} diff --git a/lib/services/tile_sharing_service.dart b/lib/services/tile_sharing_service.dart new file mode 100644 index 0000000..a3804aa --- /dev/null +++ b/lib/services/tile_sharing_service.dart @@ -0,0 +1,541 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:nsd/nsd.dart' as nsd; + +import 'offline_tile_cache_service.dart'; + +/// A discovered tile-serving peer on the local network. +class TilePeer { + final String ipAddress; + final int port; + + const TilePeer({required this.ipAddress, required this.port}); + + String get baseUrl => 'http://$ipAddress:$port'; + + @override + bool operator ==(Object other) => + other is TilePeer && other.ipAddress == ipAddress && other.port == port; + + @override + int get hashCode => Object.hash(ipAddress, port); + + @override + String toString() => 'TilePeer($ipAddress:$port)'; +} + +/// What a remote peer has available. +class PeerCatalog { + final TilePeer peer; + final List styles; + + const PeerCatalog({required this.peer, required this.styles}); +} + +class PeerTileResponse { + final Uint8List bytes; + final String? contentType; + + const PeerTileResponse({ + required this.bytes, + required this.contentType, + }); +} + +/// Progress events during a P2P sync. +sealed class PeerSyncEvent {} + +class PeerSyncStarted extends PeerSyncEvent { + final int totalTiles; + PeerSyncStarted(this.totalTiles); +} + +class PeerSyncTileDownloaded extends PeerSyncEvent { + final int downloaded; + final int total; + PeerSyncTileDownloaded({required this.downloaded, required this.total}); +} + +class PeerSyncTileSkipped extends PeerSyncEvent { + final int skipped; + final int total; + PeerSyncTileSkipped({required this.skipped, required this.total}); +} + +class PeerSyncComplete extends PeerSyncEvent { + final int downloaded; + final int skipped; + final int failed; + PeerSyncComplete({ + required this.downloaded, + required this.skipped, + required this.failed, + }); +} + +class PeerSyncCancelled extends PeerSyncEvent {} + +/// HTTP server that serves cached tiles to other devices on the +/// local network, with mDNS advertisement, peer discovery, and P2P sync. +/// +/// Protocol: +/// GET /styles → JSON array of StyleInfo +/// GET /tiles/{hash}/list → JSON array of {z, x, y} +/// GET /tiles/{hash}/{z}/{x}/{y} → tile bytes | 404 +class TileSharingService { + TileSharingService._(); + static final instance = TileSharingService._(); + + static const int defaultPort = 8347; + static const String serviceType = '_sartiles._tcp'; + + final OfflineTileCacheService _cache = OfflineTileCacheService.instance; + final http.Client _httpClient = http.Client(); + + HttpServer? _server; + nsd.Discovery? _activeDiscovery; + nsd.Registration? _activeRegistration; + + final _peersController = StreamController>.broadcast(); + final Set _discoveredPeers = {}; + + bool _syncCancelled = false; + + bool get isRunning => _server != null; + Stream> get peersStream => _peersController.stream; + Set get discoveredPeers => Set.unmodifiable(_discoveredPeers); + + // ── Server ────────────────────────────────────────────────────────────── + + Future startServer() async { + if (_server != null) return; + + try { + _server = await HttpServer.bind(InternetAddress.anyIPv4, defaultPort); + debugPrint('[TileSharing] Server started on port $defaultPort'); + + _server!.listen(_handleRequest, onError: (error) { + debugPrint('[TileSharing] Server error: $error'); + }); + + await _advertise(); + } catch (e) { + debugPrint('[TileSharing] Failed to start server: $e'); + _server = null; + } + } + + Future stopServer() async { + await _stopAdvertising(); + await _server?.close(); + _server = null; + debugPrint('[TileSharing] Server stopped'); + } + + // ── Discovery ─────────────────────────────────────────────────────────── + + Future startDiscovery() async { + if (_activeDiscovery != null) return; + + try { + _activeDiscovery = await nsd.startDiscovery(serviceType); + _activeDiscovery!.addServiceListener((service, status) { + if (service.host == null || service.port == null) return; + + final peer = TilePeer( + ipAddress: service.host!, + port: service.port!, + ); + + if (status == nsd.ServiceStatus.found) { + _discoveredPeers.add(peer); + } else { + _discoveredPeers.remove(peer); + } + _peersController.add(Set.unmodifiable(_discoveredPeers)); + }); + } catch (e) { + debugPrint('[TileSharing] Discovery error: $e'); + } + } + + Future stopPeerDiscovery() async { + if (_activeDiscovery != null) { + await nsd.stopDiscovery(_activeDiscovery!); + _activeDiscovery = null; + } + _discoveredPeers.clear(); + _peersController.add(const {}); + } + + void addManualPeer(String ipAddress, {int port = defaultPort}) { + _discoveredPeers.add(TilePeer(ipAddress: ipAddress, port: port)); + _peersController.add(Set.unmodifiable(_discoveredPeers)); + } + + void removePeer(TilePeer peer) { + _discoveredPeers.remove(peer); + _peersController.add(Set.unmodifiable(_discoveredPeers)); + } + + // ── Peer queries ──────────────────────────────────────────────────────── + + /// Fetch the catalog (available styles + tile counts) from a peer. + Future fetchPeerCatalog(TilePeer peer) async { + try { + final uri = Uri.parse('${peer.baseUrl}/styles'); + final response = + await _httpClient.get(uri).timeout(const Duration(seconds: 5)); + if (response.statusCode != 200) return null; + + final List data = jsonDecode(response.body); + final styles = data + .map((e) => StyleInfo.fromJson(e as Map)) + .toList(); + return PeerCatalog(peer: peer, styles: styles); + } catch (e) { + debugPrint('[TileSharing] fetchPeerCatalog(${peer.ipAddress}): $e'); + return null; + } + } + + /// Fetch catalogs from all discovered peers. + Future> fetchAllPeerCatalogs() async { + final futures = + _discoveredPeers.map((peer) => fetchPeerCatalog(peer)).toList(); + final results = await Future.wait(futures); + return results.whereType().toList(); + } + + /// Fetch the tile list for a style from a peer. + Future?> fetchPeerTileList( + TilePeer peer, + String styleHash, + ) async { + try { + final uri = Uri.parse('${peer.baseUrl}/tiles/$styleHash/list'); + final response = + await _httpClient.get(uri).timeout(const Duration(seconds: 10)); + if (response.statusCode != 200) return null; + + final List data = jsonDecode(response.body); + return data + .map((e) => CachedTileCoord.fromJson(e as Map)) + .toList(); + } catch (e) { + debugPrint('[TileSharing] fetchPeerTileList(${peer.ipAddress}): $e'); + return null; + } + } + + /// Fetch a single tile from a peer. Returns raw tile bytes or null. + Future fetchTileFromPeer( + TilePeer peer, + String styleHash, + int z, + int x, + int y, + ) async { + try { + final uri = Uri.parse('${peer.baseUrl}/tiles/$styleHash/$z/$x/$y'); + final response = + await _httpClient.get(uri).timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + return PeerTileResponse( + bytes: response.bodyBytes, + contentType: response.headers['content-type'], + ); + } + } catch (e) { + // Silently fail — caller will try next peer + } + return null; + } + + /// Try fetching a tile from any available peer (for the caching provider). + Future fetchFromAnyPeer( + String styleHash, + int z, + int x, + int y, + ) async { + for (final peer in _discoveredPeers) { + final tile = await fetchTileFromPeer(peer, styleHash, z, x, y); + if (tile != null) return tile; + } + return null; + } + + // ── P2P Sync ──────────────────────────────────────────────────────────── + + void cancelSync() { + _syncCancelled = true; + } + + /// Sync a style from peers: fetch their tile list, download tiles we + /// don't have, trying multiple peers in round-robin for speed. + /// + /// [peers] — which peers to pull from (all that have this style). + /// [styleHash] — which style to sync. + /// [styleMeta] — metadata to save locally (name, URL template). + Stream syncStyleFromPeers({ + required List peers, + required String styleHash, + required StyleInfo styleMeta, + int maxConcurrency = 8, + }) { + final controller = StreamController(); + _runSync( + controller: controller, + peers: peers, + styleHash: styleHash, + styleMeta: styleMeta, + maxConcurrency: maxConcurrency, + ); + return controller.stream; + } + + Future _runSync({ + required StreamController controller, + required List peers, + required String styleHash, + required StyleInfo styleMeta, + required int maxConcurrency, + }) async { + _syncCancelled = false; + + // Save style metadata locally + await _cache.saveStyleMeta( + styleHash, + displayName: styleMeta.displayName, + urlTemplate: styleMeta.urlTemplate, + region: styleMeta.region, + ); + + // Collect tile lists from all peers and merge (union) + final allTiles = {}; + for (final peer in peers) { + if (_syncCancelled) break; + final tiles = await fetchPeerTileList(peer, styleHash); + if (tiles != null) { + for (final t in tiles) { + allTiles['${t.z}/${t.x}/${t.y}'] = t; + } + } + } + + final tilesToSync = allTiles.values.toList(); + controller.add(PeerSyncStarted(tilesToSync.length)); + + if (tilesToSync.isEmpty || _syncCancelled) { + controller + .add(PeerSyncComplete(downloaded: 0, skipped: 0, failed: 0)); + await controller.close(); + return; + } + + var downloaded = 0; + var skipped = 0; + var failed = 0; + final total = tilesToSync.length; + + final semaphore = _Semaphore(maxConcurrency); + final futures = >[]; + var peerIndex = 0; + + for (final tile in tilesToSync) { + if (_syncCancelled) break; + + await semaphore.acquire(); + if (_syncCancelled) { + semaphore.release(); + break; + } + + // Round-robin across peers for parallel throughput + final peer = peers[peerIndex % peers.length]; + peerIndex++; + + final future = () async { + try { + // Skip if we already have it + if (await _cache.hasTile(styleHash, tile.z, tile.x, tile.y)) { + skipped++; + controller.add( + PeerSyncTileSkipped(skipped: skipped, total: total)); + return; + } + + // Try this peer, then fallback to others + PeerTileResponse? tileResponse = + await fetchTileFromPeer(peer, styleHash, tile.z, tile.x, tile.y); + if (tileResponse == null) { + for (final fallback in peers) { + if (fallback == peer) continue; + tileResponse = await fetchTileFromPeer( + fallback, styleHash, tile.z, tile.x, tile.y); + if (tileResponse != null) break; + } + } + + if (tileResponse != null) { + await _cache.putRawTile( + styleHash, + tile.z, + tile.x, + tile.y, + tileResponse.bytes, + contentType: tileResponse.contentType, + ); + downloaded++; + controller.add(PeerSyncTileDownloaded( + downloaded: downloaded, total: total)); + } else { + failed++; + } + } catch (_) { + failed++; + } finally { + semaphore.release(); + } + }(); + futures.add(future); + } + + await Future.wait(futures); + + if (_syncCancelled) { + controller.add(PeerSyncCancelled()); + } else { + controller.add(PeerSyncComplete( + downloaded: downloaded, + skipped: skipped, + failed: failed, + )); + } + await controller.close(); + } + + // ── mDNS ──────────────────────────────────────────────────────────────── + + Future _advertise() async { + try { + final styles = await _cache.listStyles(); + _activeRegistration = await nsd.register(nsd.Service( + name: 'MeshCore SAR Tiles', + type: serviceType, + port: defaultPort, + txt: { + 'styles': + Uint8List.fromList(utf8.encode(styles.join(','))), + }, + )); + } catch (e) { + debugPrint('[TileSharing] mDNS registration error: $e'); + } + } + + Future _stopAdvertising() async { + if (_activeRegistration != null) { + await nsd.unregister(_activeRegistration!); + _activeRegistration = null; + } + } + + // ── HTTP Server ───────────────────────────────────────────────────────── + + void _handleRequest(HttpRequest request) async { + request.response.headers.add('Access-Control-Allow-Origin', '*'); + + final path = request.uri.path; + + // GET /styles → detailed style list + if (path == '/styles') { + final styles = await _cache.listStylesDetailed(); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode(styles.map((s) => s.toJson()).toList())); + await request.response.close(); + return; + } + + // GET /tiles/{hash}/list → tile coordinate inventory + final listPattern = RegExp(r'^/tiles/([a-f0-9]+)/list$'); + final listMatch = listPattern.firstMatch(path); + if (listMatch != null) { + final styleHash = listMatch.group(1)!; + final tiles = await _cache.listTilesForStyle(styleHash); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode(tiles.map((t) => t.toJson()).toList())); + await request.response.close(); + return; + } + + // GET /tiles/{hash}/{z}/{x}/{y} → tile bytes + final tilePattern = + RegExp(r'^/tiles/([a-f0-9]+)/(\d+)/(\d+)/(\d+)(?:\.avif)?$'); + final tileMatch = tilePattern.firstMatch(path); + if (tileMatch != null) { + final styleHash = tileMatch.group(1)!; + final z = int.parse(tileMatch.group(2)!); + final x = int.parse(tileMatch.group(3)!); + final y = int.parse(tileMatch.group(4)!); + + final tile = await _cache.getTileData(styleHash, z, x, y); + if (tile != null) { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = tile.contentType == null + ? ContentType.binary + : ContentType.parse(tile.contentType!) + ..add(tile.bytes); + await request.response.close(); + return; + } + } + + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } + + void dispose() { + stopServer(); + stopPeerDiscovery(); + _httpClient.close(); + _peersController.close(); + } +} + +/// Simple counting semaphore for concurrency limiting. +class _Semaphore { + final int maxCount; + int _currentCount = 0; + final _waitQueue = >[]; + + _Semaphore(this.maxCount); + + Future acquire() async { + if (_currentCount < maxCount) { + _currentCount++; + return; + } + final completer = Completer(); + _waitQueue.add(completer); + await completer.future; + } + + void release() { + if (_waitQueue.isNotEmpty) { + _waitQueue.removeAt(0).complete(); + } else { + _currentCount--; + } + } +} diff --git a/lib/services/traffic_stats_reporting_service.dart b/lib/services/traffic_stats_reporting_service.dart new file mode 100644 index 0000000..b4785e8 --- /dev/null +++ b/lib/services/traffic_stats_reporting_service.dart @@ -0,0 +1,508 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/ble_packet_log.dart'; +import '../utils/log_rx_route_decoder.dart'; +import 'live_traffic_summary.dart'; + +class TrafficStatsCounts { + static const List packetTypeKeys = [ + 'pt_00', + 'pt_01', + 'pt_02', + 'pt_03', + 'pt_04', + 'pt_05', + 'pt_06', + 'pt_07', + 'pt_08', + 'pt_09', + 'pt_0a', + 'pt_0b', + 'pt_0c', + 'pt_0d', + 'pt_0e', + 'pt_0f', + ]; + static const List pathModeKeys = [ + 'path_mode_1b', + 'path_mode_2b', + 'path_mode_3b', + 'path_mode_none', + 'path_mode_unknown', + ]; + static const String decodeFailKey = 'decode_fail'; + static const List allKeys = [ + ...packetTypeKeys, + decodeFailKey, + ...pathModeKeys, + ]; + + final Map _values; + + TrafficStatsCounts._(this._values); + + factory TrafficStatsCounts.empty() { + return TrafficStatsCounts._( + {for (final key in allKeys) key: 0}, + ); + } + + factory TrafficStatsCounts.fromJson(Map? json) { + final counts = TrafficStatsCounts.empty(); + if (json == null) { + return counts; + } + for (final key in allKeys) { + counts._values[key] = (json[key] as num?)?.toInt() ?? 0; + } + return counts; + } + + int operator [](String key) => _values[key] ?? 0; + + bool get isEmpty => _values.values.every((value) => value == 0); + + Map toJson() { + return { + for (final key in allKeys) key: _values[key] ?? 0, + }; + } + + void increment(String key, [int amount = 1]) { + _values[key] = (_values[key] ?? 0) + amount; + } + + void incrementPacketType(int payloadType) { + if (payloadType < 0 || payloadType > 0x0F) { + increment(decodeFailKey); + return; + } + increment('pt_${payloadType.toRadixString(16).padLeft(2, '0')}'); + } + + void mergeFrom(TrafficStatsCounts other) { + for (final key in allKeys) { + increment(key, other[key]); + } + } +} + +class TrafficStatsQueuedReport { + final String reportId; + final String deviceKey6; + final DateTime windowStart; + final DateTime windowEnd; + final String appVersion; + final TrafficStatsCounts counts; + + const TrafficStatsQueuedReport({ + required this.reportId, + required this.deviceKey6, + required this.windowStart, + required this.windowEnd, + required this.appVersion, + required this.counts, + }); + + factory TrafficStatsQueuedReport.fromJson(Map json) { + return TrafficStatsQueuedReport( + reportId: (json['reportId'] as String?) ?? '', + deviceKey6: (json['deviceKey6'] as String?) ?? '', + windowStart: DateTime.parse(json['windowStart'] as String).toUtc(), + windowEnd: DateTime.parse(json['windowEnd'] as String).toUtc(), + appVersion: (json['appVersion'] as String?) ?? 'unknown', + counts: TrafficStatsCounts.fromJson( + json['counts'] as Map?, + ), + ); + } + + Map toJson() { + return { + 'reportId': reportId, + 'deviceKey6': deviceKey6, + 'windowStart': windowStart.toIso8601String(), + 'windowEnd': windowEnd.toIso8601String(), + 'appVersion': appVersion, + 'counts': counts.toJson(), + }; + } +} + +class TrafficStatsReportingService extends ChangeNotifier { + static const String workerBaseUrl = 'https://mcstats.dz0ny.dev'; + static final Uri dashboardUri = Uri.parse(workerBaseUrl); + static final Uri ingestUri = Uri.parse('$workerBaseUrl/api/ingest'); + + static const bool defaultEnabled = true; + static const int defaultIntervalMinutes = 5; + static const String _enabledKey = 'traffic_stats_reporting_enabled'; + static const String _legacyIntervalKey = + 'traffic_stats_reporting_interval_minutes'; + static const String _queueKey = 'traffic_stats_reporting_queue'; + static const String _lastSuccessAtKey = + 'traffic_stats_reporting_last_success_at'; + static const String _lastErrorKey = 'traffic_stats_reporting_last_error'; + static const Duration _retryInterval = Duration(seconds: 30); + + final http.Client _client; + final bool _ownsClient; + final DateTime Function() _now; + final Future Function() _prefsProvider; + final Future Function() _appVersionProvider; + final Map _openWindows = + {}; + final List _queue = []; + + String? Function()? _deviceKey6Provider; + Timer? _retryTimer; + String? _appVersion; + bool _enabled = defaultEnabled; + DateTime? _lastSuccessAt; + String? _lastError; + bool _isInitialized = false; + bool _isFlushingQueue = false; + + TrafficStatsReportingService({ + http.Client? client, + DateTime Function()? now, + Future Function()? prefsProvider, + Future Function()? appVersionProvider, + }) : _client = client ?? http.Client(), + _ownsClient = client == null, + _now = now ?? DateTime.now, + _prefsProvider = prefsProvider ?? SharedPreferences.getInstance, + _appVersionProvider = appVersionProvider ?? _defaultAppVersionProvider; + + bool get isEnabled => _enabled; + int get intervalMinutes => defaultIntervalMinutes; + DateTime? get lastSuccessAt => _lastSuccessAt; + String? get lastError => _lastError; + bool get isInitialized => _isInitialized; + bool get isFlushingQueue => _isFlushingQueue; + int get pendingUploadCount => _queue.length; + + Future initialize({ + required String? Function() deviceKey6Provider, + }) async { + _deviceKey6Provider = deviceKey6Provider; + final prefs = await _prefsProvider(); + _enabled = prefs.getBool(_enabledKey) ?? defaultEnabled; + if (prefs.containsKey(_legacyIntervalKey)) { + await prefs.remove(_legacyIntervalKey); + } + final queueJson = prefs.getString(_queueKey); + if (queueJson != null && queueJson.isNotEmpty) { + final decoded = jsonDecode(queueJson); + if (decoded is List) { + _queue + ..clear() + ..addAll( + decoded.whereType>().map( + TrafficStatsQueuedReport.fromJson, + ), + ); + } + } + final lastSuccessAt = prefs.getString(_lastSuccessAtKey); + if (lastSuccessAt != null && lastSuccessAt.isNotEmpty) { + _lastSuccessAt = DateTime.tryParse(lastSuccessAt)?.toUtc(); + } + final lastError = prefs.getString(_lastErrorKey); + if (lastError != null && lastError.isNotEmpty) { + _lastError = lastError; + } + try { + _appVersion = await _appVersionProvider(); + } catch (_) { + _appVersion = 'unknown'; + } + _retryTimer?.cancel(); + _retryTimer = Timer.periodic(_retryInterval, (_) { + unawaited(flushPendingUploads()); + }); + _isInitialized = true; + notifyListeners(); + await flushPendingUploads(); + } + + Future setEnabled(bool enabled) async { + if (_enabled == enabled) { + return; + } + _enabled = enabled; + _lastError = null; + if (!enabled) { + _openWindows.clear(); + } + await _saveState(); + notifyListeners(); + if (enabled) { + unawaited(flushPendingUploads()); + } + } + + Future processLogs(List logs) async { + if (!_enabled || logs.isEmpty) { + if (_enabled) { + await flushPendingUploads(); + } + return; + } + + var changed = false; + for (final log in logs) { + if (!LiveTrafficSummary.isRxDataLog(log)) { + continue; + } + final counts = _openWindows.putIfAbsent( + _windowStartFor(log.timestamp).millisecondsSinceEpoch, + TrafficStatsCounts.empty, + ); + final route = LogRxRouteDecoder.decode(log.rawData); + if (route == null) { + counts.increment(TrafficStatsCounts.decodeFailKey); + } else { + counts.incrementPacketType(route.payloadType); + } + counts.increment(_pathModeKeyFor(log.rawData, route)); + changed = true; + } + + if (!changed) { + await flushPendingUploads(); + return; + } + + final queuedReports = _queueClosedWindows(); + if (queuedReports > 0) { + await _saveState(); + } + notifyListeners(); + await flushPendingUploads(); + } + + Future flushPendingUploads() async { + if (!_enabled || _queue.isEmpty || _isFlushingQueue) { + return; + } + final deviceKey6 = _deviceKey6Provider?.call(); + if (deviceKey6 == null || deviceKey6.isEmpty) { + return; + } + + _isFlushingQueue = true; + notifyListeners(); + try { + while (_queue.isNotEmpty && _enabled) { + final report = _queue.first; + final response = await _client.post( + ingestUri, + headers: const { + 'content-type': 'application/json', + }, + body: jsonEncode(report.toJson()), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + _lastError = 'Upload failed (${response.statusCode})'; + await _saveState(); + notifyListeners(); + return; + } + + _queue.removeAt(0); + _lastError = null; + _lastSuccessAt = _now().toUtc(); + await _saveState(); + notifyListeners(); + } + } catch (error) { + _lastError = 'Upload failed: $error'; + await _saveState(); + notifyListeners(); + } finally { + _isFlushingQueue = false; + notifyListeners(); + } + } + + int _queueClosedWindows() { + final deviceKey6 = _deviceKey6Provider?.call(); + if (deviceKey6 == null || deviceKey6.isEmpty) { + return 0; + } + + final now = _now().toUtc(); + final closable = _openWindows.keys + .where((windowStartMs) { + final windowStart = DateTime.fromMillisecondsSinceEpoch( + windowStartMs, + isUtc: true, + ); + final windowEnd = windowStart.add( + Duration(minutes: defaultIntervalMinutes), + ); + return !windowEnd.isAfter(now); + }) + .toList() + ..sort(); + + for (final windowStartMs in closable) { + final windowStart = DateTime.fromMillisecondsSinceEpoch( + windowStartMs, + isUtc: true, + ); + final counts = _openWindows.remove(windowStartMs); + if (counts == null || counts.isEmpty) { + continue; + } + final reportId = '$deviceKey6:${windowStart.toIso8601String()}'; + final existingIndex = _queue.indexWhere( + (report) => report.reportId == reportId, + ); + if (existingIndex != -1) { + _queue[existingIndex].counts.mergeFrom(counts); + continue; + } + _queue.add( + TrafficStatsQueuedReport( + reportId: reportId, + deviceKey6: deviceKey6, + windowStart: windowStart, + windowEnd: windowStart.add( + Duration(minutes: defaultIntervalMinutes), + ), + appVersion: _appVersion ?? 'unknown', + counts: counts, + ), + ); + } + return closable.length; + } + + DateTime _windowStartFor(DateTime timestamp) { + final utc = timestamp.toUtc(); + final alignedMinute = + utc.minute - (utc.minute % defaultIntervalMinutes); + return DateTime.utc( + utc.year, + utc.month, + utc.day, + utc.hour, + alignedMinute, + ); + } + + String _pathModeKeyFor(Uint8List rawData, DecodedLogRxRoute? route) { + if (route != null) { + if (route.pathBytes.isEmpty) { + return 'path_mode_none'; + } + switch (route.hashSize) { + case 1: + return 'path_mode_1b'; + case 2: + return 'path_mode_2b'; + case 3: + return 'path_mode_3b'; + } + return 'path_mode_unknown'; + } + return _pathModeKeyFromRawData(rawData); + } + + String _pathModeKeyFromRawData(Uint8List rawData) { + if (rawData.length < 5 || + rawData.first != LiveTrafficSummary.logRxDataResponseCode) { + return 'path_mode_unknown'; + } + + final rawPacketData = rawData.sublist(3); + if (rawPacketData.length < 2) { + return 'path_mode_unknown'; + } + + final header = rawPacketData[0]; + final routeType = header & 0x03; + var index = 1; + if (routeType == 0x00 || routeType == 0x03) { + if (rawPacketData.length < index + 5) { + return 'path_mode_unknown'; + } + index += 4; + } + + if (rawPacketData.length <= index) { + return 'path_mode_unknown'; + } + + final pathDescriptor = rawPacketData[index]; + final pathByteLen = LogRxRouteDecoder.descriptorByteLength(pathDescriptor); + if (pathByteLen == null) { + return 'path_mode_unknown'; + } + if (rawPacketData.length < index + 1 + pathByteLen) { + return 'path_mode_unknown'; + } + if (pathByteLen == 0) { + return 'path_mode_none'; + } + + final hashSize = LogRxRouteDecoder.descriptorHashSize(pathDescriptor); + switch (hashSize) { + case 1: + return 'path_mode_1b'; + case 2: + return 'path_mode_2b'; + case 3: + return 'path_mode_3b'; + } + return 'path_mode_unknown'; + } + + Future _saveState() async { + final prefs = await _prefsProvider(); + await prefs.setBool(_enabledKey, _enabled); + await prefs.remove(_legacyIntervalKey); + await prefs.setString( + _queueKey, + jsonEncode(_queue.map((report) => report.toJson()).toList()), + ); + if (_lastSuccessAt == null) { + await prefs.remove(_lastSuccessAtKey); + } else { + await prefs.setString( + _lastSuccessAtKey, + _lastSuccessAt!.toIso8601String(), + ); + } + if (_lastError == null || _lastError!.isEmpty) { + await prefs.remove(_lastErrorKey); + } else { + await prefs.setString(_lastErrorKey, _lastError!); + } + } + + static Future _defaultAppVersionProvider() async { + final info = await PackageInfo.fromPlatform(); + if (info.buildNumber.isEmpty || info.buildNumber == '0') { + return info.version; + } + return '${info.version}+${info.buildNumber}'; + } + + @override + void dispose() { + _retryTimer?.cancel(); + if (_ownsClient) { + _client.close(); + } + super.dispose(); + } +} diff --git a/lib/services/trail_color_service.dart b/lib/services/trail_color_service.dart new file mode 100644 index 0000000..0779dca --- /dev/null +++ b/lib/services/trail_color_service.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import '../models/contact.dart'; + +/// Service for assigning consistent colors to contact trails +/// Uses emoji-based semantic mapping with deterministic hash fallback +class TrailColorService { + // 64-color pastel palette optimized for visibility on all map types + // Organized by hue families for better distribution + // Avoids red/orange/yellow spectrum to prevent confusion with fire markers + // Avoids pure blue (#2196F3) which is reserved for user trail + static final List _colorPalette = [ + // Pinks & Light Corals (8) + const Color(0xFFFFB6C1), // Light Pink + const Color(0xFFFF7F7F), // Coral + const Color(0xFFFFC0CB), // Pink + const Color(0xFFFFB3BA), // Pastel Pink + const Color(0xFFFF9AA2), // Light Coral + const Color(0xFFFFDAE9), // Pale Pink + const Color(0xFFFAA0B8), // Pastel Rose + const Color(0xFFFF8FA3), // Salmon Pink + + // Purples & Plums (8) + const Color(0xFFE6E6FA), // Lavender + const Color(0xFFDDA0DD), // Plum + const Color(0xFFD8BFD8), // Thistle + const Color(0xFFDDA5E9), // Pastel Purple + const Color(0xFFE0BBE4), // Mauve + const Color(0xFFC5A3E0), // Light Purple + const Color(0xFFB19CD9), // Medium Lavender + const Color(0xFFAF9FCD), // Wisteria + + // Blues & Sky (12) + const Color(0xFF87CEEB), // Sky Blue + const Color(0xFFB0E0E6), // Powder Blue + const Color(0xFFADD8E6), // Light Blue + const Color(0xFF87CEFA), // Light Sky Blue + const Color(0xFFB0C4DE), // Light Steel Blue + const Color(0xFF9BB8D3), // Pastel Blue + const Color(0xFF89CFF0), // Baby Blue + const Color(0xFFA2C8EC), // Columbia Blue + const Color(0xFF7FB3D5), // Pale Blue + const Color(0xFF6A9FB5), // Air Force Blue + const Color(0xFF8DB4D2), // Soft Blue + const Color(0xFF7BA5C9), // Light Denim + + // Cyans & Teals (8) + const Color(0xFF5F9EA0), // Cadet Blue + const Color(0xFF7FFFD4), // Aquamarine + const Color(0xFF98D8C8), // Mint + const Color(0xFF82E0D5), // Pale Cyan + const Color(0xFF8FD8D8), // Light Teal + const Color(0xFF81C0BB), // Cadet Teal + const Color(0xFF72B0A8), // Medium Teal + const Color(0xFF6FA09E), // Soft Teal + + // Greens & Mints (8) + const Color(0xFF90EE90), // Light Green + const Color(0xFF98D8B4), // Celadon + const Color(0xFFA8E4A0), // Granny Smith + const Color(0xFFB2E8B2), // Tea Green + const Color(0xFF9FD8AF), // Eton Blue + const Color(0xFF8FC49F), // Pastel Green + const Color(0xFF7EB693), // Cambridge Blue + const Color(0xFF73A685), // Russian Green + + // Beiges & Tans (12) + const Color(0xFFD2B48C), // Tan + const Color(0xFFDEB887), // Burlywood + const Color(0xFFE0D8B0), // Beige + const Color(0xFFFFDAB9), // Peach + const Color(0xFFFFE4B5), // Moccasin + const Color(0xFFFFF8DC), // Cornsilk + const Color(0xFFE8D5C4), // Champagne + const Color(0xFFD4C5B9), // Dust + const Color(0xFFC9B8A9), // Khaki + const Color(0xFFBCAA99), // Cashmere + const Color(0xFFB09B87), // Taupe + const Color(0xFFA58F7A), // Mocha + + // Grays & Silvers (8) + const Color(0xFFD3D3D3), // Light Gray + const Color(0xFFC0C0C0), // Silver + const Color(0xFFBCBCBC), // Bright Gray + const Color(0xFFB2B2B2), // Medium Gray + const Color(0xFFA9A9A9), // Dark Gray + const Color(0xFF9E9E9E), // Gray + const Color(0xFF8E8E8E), // Taupe Gray + const Color(0xFF7E7E7E), // Granite + ]; + + // Emoji to color mapping for SAR roles + // Uses pastel semantic colors for high visibility on maps + // Avoids red/orange/yellow to prevent confusion with fire markers + static final Map _emojiColorMap = { + // Emergency Services - Firefighters + '🚒': Color(0xFFFF7F7F), // Fire engine → Coral + '🧑‍🚒': Color(0xFFFF7F7F), // Firefighter → Coral + '👨‍🚒': Color(0xFFFF7F7F), // Firefighter → Coral + '👩‍🚒': Color(0xFFFF7F7F), // Firefighter → Coral + '🔥': Color(0xFFFFB6C1), // Fire → Light Pink + + // Emergency Services - Medical + '🚑': Color(0xFF7FFFD4), // Ambulance → Mint (medical cross) + '👨‍⚕️': Color(0xFF7FFFD4), // Health worker → Mint + '👩‍⚕️': Color(0xFF7FFFD4), // Health worker → Mint + '🧑‍⚕️': Color(0xFF7FFFD4), // Health worker → Mint + '⚕️': Color(0xFF7FFFD4), // Medical symbol → Mint + + // Emergency Services - Police + '👮': Color(0xFF87CEEB), // Police → Light Blue + '👮‍♂️': Color(0xFF87CEEB), // Police → Light Blue + '👮‍♀️': Color(0xFF87CEEB), // Police → Light Blue + '🚔': Color(0xFF87CEEB), // Police car → Light Blue + + // Emergency Services - Aviation + '🧑‍✈️': Color(0xFFB0E0E6), // Pilot → Sky Blue + '👨‍✈️': Color(0xFFB0E0E6), // Pilot → Sky Blue + '👩‍✈️': Color(0xFFB0E0E6), // Pilot → Sky Blue + '🚁': Color(0xFFE6E6FA), // Helicopter → Lavender + + // SAR Roles - Mountain/Alpine + '🏔️': Color(0xFFD2B48C), // Mountain → Tan + '⛰️': Color(0xFFD2B48C), // Mountain → Tan + '🧗': Color(0xFFD2B48C), // Climber → Tan + '🧗‍♂️': Color(0xFFD2B48C), // Climber → Tan + '🧗‍♀️': Color(0xFFD2B48C), // Climber → Tan + '🥾': Color(0xFFDEB887), // Hiking boot → Burlywood + + // SAR Roles - K9 Unit + '🐕': Color(0xFFFFDAB9), // Dog → Peach + '🐶': Color(0xFFFFDAB9), // Dog → Peach + '🦮': Color(0xFFFFDAB9), // Service dog → Peach + + // SAR Roles - Water Rescue + '🚤': Color(0xFF87CEEB), // Speedboat → Sky Blue + '⛵': Color(0xFF87CEEB), // Sailboat → Sky Blue + '🏊': Color(0xFF5F9EA0), // Swimmer → Cadet Blue + '🏊‍♂️': Color(0xFF5F9EA0), // Swimmer → Cadet Blue + '🏊‍♀️': Color(0xFF5F9EA0), // Swimmer → Cadet Blue + + // Team Roles - Leadership + '🎯': Color(0xFFFFB6C1), // Target → Light Pink (team leader) + '⭐': Color(0xFFFFE4B5), // Star → Moccasin (coordinator) + '👑': Color(0xFFFFE4B5), // Crown → Moccasin (leader) + + // Team Roles - Communication + '📡': Color(0xFF5F9EA0), // Satellite → Cadet Blue (radio/comms) + '📻': Color(0xFF5F9EA0), // Radio → Cadet Blue + '📞': Color(0xFF5F9EA0), // Phone → Cadet Blue + + // Team Roles - Navigation + '🗺️': Color(0xFF87CEEB), // Map → Sky Blue (navigator) + '🧭': Color(0xFF87CEEB), // Compass → Sky Blue + '📍': Color(0xFFFF7F7F), // Pin → Coral (location marker) + + // Team Roles - Documentation + '📷': Color(0xFFDDA0DD), // Camera → Plum + '📹': Color(0xFFDDA0DD), // Video camera → Plum + '📝': Color(0xFFE0E0A0), // Note → Khaki (scribe) + + // Equipment + '🔦': Color(0xFFFFE4B5), // Flashlight → Moccasin + '⚡': Color(0xFFFFE4B5), // Lightning → Moccasin (power/energy) + '🔋': Color(0xFF7FFFD4), // Battery → Mint + '🎒': Color(0xFFDEB887), // Backpack → Burlywood + + // Generic Person Icons + '👤': Color(0xFFD3D3D3), // Silhouette → Light Gray + '🧑': Color(0xFFD3D3D3), // Person → Light Gray + '👨': Color(0xFFD3D3D3), // Man → Light Gray + '👩': Color(0xFFD3D3D3), // Woman → Light Gray + '👥': Color(0xFFC0C0C0), // People → Silver + }; + + /// Get trail color for a contact + /// Priority: Emoji mapping > Name hash > Default + /// Returns fully opaque color - alpha transparency applied by caller + static Color getTrailColor(Contact contact) { + // 1. Try emoji-based color mapping + if (contact.roleEmoji != null) { + final emojiColor = _emojiColorMap[contact.roleEmoji]; + if (emojiColor != null) { + return emojiColor; + } + } + + // 2. Deterministic color based on display name + // Use display name (without emoji) for consistent hashing + final name = contact.displayName.isNotEmpty + ? contact.displayName + : contact.publicKeyHex; + + final hash = _hashString(name); + final colorIndex = hash % _colorPalette.length; // 0-63 + + return _colorPalette[colorIndex]; + } + + /// Simple string hash function (DJB2 algorithm) + /// Same algorithm used for echo detection in the app + static int _hashString(String str) { + int hash = 5381; + for (int i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) + str.codeUnitAt(i); + hash = hash & 0xFFFFFFFF; // Keep 32-bit + } + return hash.abs(); + } + + /// Get all unique colors currently in use by contacts with trails + static List getActiveColors(List contacts) { + final colors = {}; + for (final contact in contacts) { + if (contact.advertHistory.length >= 2) { + colors.add(getTrailColor(contact)); + } + } + return colors.toList(); + } + + /// Check if a color is from emoji mapping (semantic) vs hash-based + static bool isSemanticColor(Contact contact) { + if (contact.roleEmoji == null) return false; + return _emojiColorMap.containsKey(contact.roleEmoji); + } +} diff --git a/lib/services/update_checker_service.dart b/lib/services/update_checker_service.dart new file mode 100644 index 0000000..63896f4 --- /dev/null +++ b/lib/services/update_checker_service.dart @@ -0,0 +1,234 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../models/update_info.dart'; +import 'build_info_service.dart'; + +/// Service for checking if a new app version is available +/// Compares current build's commit hash with latest GitHub release +class UpdateCheckerService { + static final UpdateCheckerService _instance = + UpdateCheckerService._internal(); + factory UpdateCheckerService() => _instance; + UpdateCheckerService._internal(); + + final BuildInfoService _buildInfoService = BuildInfoService(); + + static const String _repoOwner = 'dz0ny'; + static const String _repoName = 'meshcore-sar'; + static const String _latestReleaseUrl = + 'https://api.github.com/repos/$_repoOwner/$_repoName/releases/latest'; + + /// Check if an update is available + /// Returns UpdateInfo with availability status and download URL if available + Future checkForUpdate() async { + try { + // Get current build's commit hash + final currentCommitHash = await _buildInfoService.getCommitHash(); + + // Skip check for dev builds (local development) + if (currentCommitHash == 'dev' || currentCommitHash == 'unknown') { + debugPrint( + '[UpdateChecker] Skipping update check for dev/unknown build', + ); + return UpdateInfo.noUpdate(currentCommitHash); + } + + debugPrint('[UpdateChecker] Current commit hash: $currentCommitHash'); + debugPrint( + '[UpdateChecker] Fetching latest release from: $_latestReleaseUrl', + ); + + // Fetch latest release from GitHub + final response = await http + .get( + Uri.parse(_latestReleaseUrl), + headers: { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + ) + .timeout( + const Duration(seconds: 10), + onTimeout: () { + debugPrint('[UpdateChecker] Latest release fetch timed out'); + throw Exception('Latest release fetch timed out'); + }, + ); + + if (response.statusCode != 200) { + debugPrint( + '[UpdateChecker] Failed to fetch release: ${response.statusCode}', + ); + return UpdateInfo.noUpdate(currentCommitHash); + } + + // Parse release JSON + final Map release = json.decode(response.body); + final tagName = release['tag_name'] as String?; + final targetCommitish = release['target_commitish'] as String?; + final publishedAt = release['published_at'] as String?; + final assets = release['assets'] as List?; + + if (tagName == null) { + debugPrint('[UpdateChecker] Invalid release: missing tag_name'); + return UpdateInfo.noUpdate(currentCommitHash); + } + + debugPrint('[UpdateChecker] Latest release tag: $tagName'); + if (targetCommitish != null && targetCommitish.isNotEmpty) { + debugPrint( + '[UpdateChecker] Release target commitish: $targetCommitish', + ); + } + + final isUpdateAvailable = await _isUpdateAvailable( + currentCommitHash: currentCommitHash, + releaseTag: tagName, + targetCommitish: targetCommitish, + ); + + if (!isUpdateAvailable) { + debugPrint('[UpdateChecker] No update available'); + return UpdateInfo.noUpdate(currentCommitHash); + } + + // Find Android APK in release assets + final String? apkUrl = _findAndroidApkUrl(assets); + + if (apkUrl == null) { + debugPrint( + '[UpdateChecker] Update available but no APK found in artifacts', + ); + return UpdateInfo.noUpdate(currentCommitHash); + } + + debugPrint('[UpdateChecker] Update available! APK URL: $apkUrl'); + + return UpdateInfo.available( + currentCommitHash: currentCommitHash, + latestCommitHash: _formatLatestVersion(targetCommitish, tagName), + downloadUrl: apkUrl, + buildId: tagName, + timestamp: publishedAt, + ); + } catch (e) { + debugPrint('[UpdateChecker] Error checking for update: $e'); + // Return no update on error to avoid disrupting app startup + final currentCommitHash = await _buildInfoService.getCommitHash(); + return UpdateInfo.noUpdate(currentCommitHash); + } + } + + /// Compare two commit hashes (handles both full SHA and short format) + bool _compareCommitHashes(String current, String latest) { + // Normalize to lowercase for comparison + final currentLower = current.toLowerCase(); + final latestLower = latest.toLowerCase(); + + // Direct match + if (currentLower == latestLower) return true; + + // Check if current is short form of latest + if (latestLower.startsWith(currentLower)) return true; + + // Check if latest is short form of current + if (currentLower.startsWith(latestLower)) return true; + + return false; + } + + Future _isUpdateAvailable({ + required String currentCommitHash, + required String releaseTag, + required String? targetCommitish, + }) async { + // Prefer direct SHA compare when release target is a hash. + if (_looksLikeSha(targetCommitish)) { + return !_compareCommitHashes(currentCommitHash, targetCommitish!); + } + + // Fallback: ask GitHub how current commit compares to the release tag. + final compareResult = await _compareWithReleaseTag( + currentCommitHash, + releaseTag, + ); + if (compareResult != null) { + return compareResult; + } + + // If we cannot compare, do not force update prompts. + return false; + } + + Future _compareWithReleaseTag( + String currentCommitHash, + String releaseTag, + ) async { + try { + final compareUrl = + 'https://api.github.com/repos/$_repoOwner/$_repoName/compare/$currentCommitHash...$releaseTag'; + final response = await http + .get( + Uri.parse(compareUrl), + headers: { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + debugPrint( + '[UpdateChecker] Compare API failed: ${response.statusCode}', + ); + return null; + } + + final Map comparison = json.decode(response.body); + final status = comparison['status'] as String?; + debugPrint('[UpdateChecker] Compare status: $status'); + + if (status == 'behind') return true; + if (status == 'identical' || status == 'ahead') return false; + + // "diverged" means the release and current commit differ. + if (status == 'diverged') return true; + } catch (e) { + debugPrint('[UpdateChecker] Compare API error: $e'); + } + return null; + } + + bool _looksLikeSha(String? value) { + if (value == null) return false; + final v = value.trim(); + if (v.length < 7 || v.length > 40) return false; + return RegExp(r'^[a-fA-F0-9]+$').hasMatch(v); + } + + String _formatLatestVersion(String? targetCommitish, String tagName) { + if (_looksLikeSha(targetCommitish)) { + return targetCommitish!.substring(0, 7).toLowerCase(); + } + return tagName; + } + + /// Find Android APK URL in release assets list + String? _findAndroidApkUrl(List? assets) { + if (assets == null || assets.isEmpty) return null; + + for (final asset in assets) { + if (asset is! Map) continue; + final name = (asset['name'] as String?)?.toLowerCase() ?? ''; + if (!name.endsWith('.apk')) continue; + + final browserDownloadUrl = asset['browser_download_url'] as String?; + if (browserDownloadUrl != null && browserDownloadUrl.isNotEmpty) { + return browserDownloadUrl; + } + } + + return null; + } +} diff --git a/lib/services/validation_service.dart b/lib/services/validation_service.dart new file mode 100644 index 0000000..95615a0 --- /dev/null +++ b/lib/services/validation_service.dart @@ -0,0 +1,511 @@ +/// Centralized validation service for form validation, coordinate validation, +/// input sanitization, and common validation patterns used across the app. +/// +/// This service provides structured validation results with helpful error messages +/// and includes parse + validate methods for complex inputs. +class ValidationService { + // Singleton pattern + static final ValidationService _instance = ValidationService._internal(); + factory ValidationService() => _instance; + ValidationService._internal(); + + // ============================================================================ + // COORDINATE VALIDATION + // ============================================================================ + + /// Validates latitude value (-90.0 to +90.0) + ValidationResult validateLatitude(double? lat) { + if (lat == null) { + return const ValidationResult.invalid('Latitude is required'); + } + if (lat < -90.0 || lat > 90.0) { + return const ValidationResult.invalid( + 'Latitude must be between -90.0 and +90.0', + ); + } + return const ValidationResult.valid(); + } + + /// Validates longitude value (-180.0 to +180.0) + ValidationResult validateLongitude(double? lon) { + if (lon == null) { + return const ValidationResult.invalid('Longitude is required'); + } + if (lon < -180.0 || lon > 180.0) { + return const ValidationResult.invalid( + 'Longitude must be between -180.0 and +180.0', + ); + } + return const ValidationResult.valid(); + } + + /// Validates both latitude and longitude coordinates + ValidationResult validateCoordinates(double? lat, double? lon) { + final latResult = validateLatitude(lat); + if (!latResult.isValid) return latResult; + + final lonResult = validateLongitude(lon); + if (!lonResult.isValid) return lonResult; + + return const ValidationResult.valid(); + } + + // ============================================================================ + // COORDINATE BOUNDS VALIDATION (for region downloads) + // ============================================================================ + + /// Validates coordinate bounds for map region downloads + /// + /// Checks: + /// - All coordinates are valid numbers + /// - North > South + /// - East > West + /// - Coordinates are within valid ranges + ValidationResult validateBounds({ + required double? north, + required double? south, + required double? east, + required double? west, + }) { + // Validate all coordinates exist + if (north == null || south == null || east == null || west == null) { + return const ValidationResult.invalid( + 'All coordinates are required (North, South, East, West)', + ); + } + + // Validate individual coordinate ranges + final northResult = validateLatitude(north); + if (!northResult.isValid) { + return ValidationResult.invalid('North: ${northResult.errorMessage}'); + } + + final southResult = validateLatitude(south); + if (!southResult.isValid) { + return ValidationResult.invalid('South: ${southResult.errorMessage}'); + } + + final eastResult = validateLongitude(east); + if (!eastResult.isValid) { + return ValidationResult.invalid('East: ${eastResult.errorMessage}'); + } + + final westResult = validateLongitude(west); + if (!westResult.isValid) { + return ValidationResult.invalid('West: ${westResult.errorMessage}'); + } + + // Validate bounds relationships + if (north <= south) { + return const ValidationResult.invalid( + 'North must be greater than South', + ); + } + + if (east <= west) { + return const ValidationResult.invalid( + 'East must be greater than West', + ); + } + + return const ValidationResult.valid(); + } + + // ============================================================================ + // RADIO PARAMETER VALIDATION + // ============================================================================ + + /// Validates LoRa radio frequency in MHz (137.0 to 1020.0 MHz) + ValidationResult validateFrequency(double? freqMhz) { + if (freqMhz == null) { + return const ValidationResult.invalid('Frequency is required'); + } + if (freqMhz < 137.0 || freqMhz > 1020.0) { + return const ValidationResult.invalid( + 'Frequency must be between 137.0 and 1020.0 MHz', + ); + } + return const ValidationResult.valid(); + } + + /// Validates TX power in dBm (-9 to +22 dBm typical, or up to maxPower) + /// + /// If maxPower is provided, uses that as upper limit. + /// Otherwise defaults to +22 dBm. + ValidationResult validateTxPower(int? powerDbm, int? maxPower) { + if (powerDbm == null) { + return const ValidationResult.invalid('TX power is required'); + } + + final max = maxPower ?? 22; + + if (powerDbm < -9) { + return const ValidationResult.invalid( + 'TX power must be at least -9 dBm', + ); + } + + if (powerDbm > max) { + return ValidationResult.invalid( + 'TX power must not exceed $max dBm', + ); + } + + return const ValidationResult.valid(); + } + + /// Validates LoRa bandwidth index (0-9) + /// + /// Valid bandwidth indices: + /// 0=7.8kHz, 1=10.4kHz, 2=15.6kHz, 3=20.8kHz, 4=31.25kHz, + /// 5=41.7kHz, 6=62.5kHz, 7=125kHz, 8=250kHz, 9=500kHz + ValidationResult validateBandwidth(int? bwIndex) { + if (bwIndex == null) { + return const ValidationResult.invalid('Bandwidth is required'); + } + if (bwIndex < 0 || bwIndex > 9) { + return const ValidationResult.invalid( + 'Bandwidth index must be between 0 and 9', + ); + } + return const ValidationResult.valid(); + } + + /// Validates LoRa spreading factor (7-12) + ValidationResult validateSpreadingFactor(int? sf) { + if (sf == null) { + return const ValidationResult.invalid('Spreading factor is required'); + } + if (sf < 7 || sf > 12) { + return const ValidationResult.invalid( + 'Spreading factor must be between 7 and 12', + ); + } + return const ValidationResult.valid(); + } + + /// Validates LoRa coding rate (5-8) + ValidationResult validateCodingRate(int? cr) { + if (cr == null) { + return const ValidationResult.invalid('Coding rate is required'); + } + if (cr < 5 || cr > 8) { + return const ValidationResult.invalid( + 'Coding rate must be between 5 and 8', + ); + } + return const ValidationResult.valid(); + } + + // ============================================================================ + // DISTANCE AND TIME VALIDATION + // ============================================================================ + + /// Validates distance in meters + /// + /// Optional min and max bounds can be provided. + /// Defaults to 1m minimum if not specified. + ValidationResult validateDistance( + double? meters, { + double? min, + double? max, + }) { + if (meters == null) { + return const ValidationResult.invalid('Distance is required'); + } + + final minValue = min ?? 1.0; + + if (meters < minValue) { + return ValidationResult.invalid( + 'Distance must be at least ${minValue.toStringAsFixed(0)}m', + ); + } + + if (max != null && meters > max) { + return ValidationResult.invalid( + 'Distance must not exceed ${max.toStringAsFixed(0)}m', + ); + } + + return const ValidationResult.valid(); + } + + /// Validates time interval in seconds + /// + /// Optional min and max bounds can be provided. + /// Defaults to 10 seconds minimum if not specified. + ValidationResult validateTimeInterval( + int? seconds, { + int? min, + int? max, + }) { + if (seconds == null) { + return const ValidationResult.invalid('Time interval is required'); + } + + final minValue = min ?? 10; + + if (seconds < minValue) { + return ValidationResult.invalid( + 'Time interval must be at least ${minValue}s', + ); + } + + if (max != null && seconds > max) { + return ValidationResult.invalid( + 'Time interval must not exceed ${max}s', + ); + } + + return const ValidationResult.valid(); + } + + // ============================================================================ + // ZOOM LEVEL VALIDATION + // ============================================================================ + + /// Validates map zoom level (1-19 for most tile sources) + ValidationResult validateZoomLevel(int? zoom) { + if (zoom == null) { + return const ValidationResult.invalid('Zoom level is required'); + } + if (zoom < 1 || zoom > 19) { + return const ValidationResult.invalid( + 'Zoom level must be between 1 and 19', + ); + } + return const ValidationResult.valid(); + } + + // ============================================================================ + // NAME AND TEXT VALIDATION + // ============================================================================ + + /// Validates name/text field + /// + /// Checks for: + /// - Non-empty after trimming + /// - Maximum length (defaults to 32 characters) + ValidationResult validateName(String? name, {int? maxLength}) { + if (name == null || name.trim().isEmpty) { + return const ValidationResult.invalid('Name cannot be empty'); + } + + final max = maxLength ?? 32; + + if (name.length > max) { + return ValidationResult.invalid( + 'Name must not exceed $max characters', + ); + } + + return const ValidationResult.valid(); + } + + /// Validates password field + /// + /// Checks for: + /// - Non-empty + /// - Maximum length of 15 characters (MeshCore protocol limit) + ValidationResult validatePassword(String? password) { + if (password == null || password.isEmpty) { + return const ValidationResult.invalid('Password cannot be empty'); + } + + if (password.length > 15) { + return const ValidationResult.invalid( + 'Password must not exceed 15 characters', + ); + } + + return const ValidationResult.valid(); + } + + // ============================================================================ + // PARSE AND VALIDATE METHODS + // ============================================================================ + + /// Parses and validates latitude string + /// + /// Returns ParseResult with parsed value or error message. + ParseResult parseLatitude(String text) { + if (text.trim().isEmpty) { + return const ParseResult.error('Latitude is required'); + } + + final value = double.tryParse(text.trim()); + if (value == null) { + return const ParseResult.error('Invalid number format'); + } + + final validation = validateLatitude(value); + if (!validation.isValid) { + return ParseResult.error(validation.errorMessage!); + } + + return ParseResult.success(value); + } + + /// Parses and validates longitude string + /// + /// Returns ParseResult with parsed value or error message. + ParseResult parseLongitude(String text) { + if (text.trim().isEmpty) { + return const ParseResult.error('Longitude is required'); + } + + final value = double.tryParse(text.trim()); + if (value == null) { + return const ParseResult.error('Invalid number format'); + } + + final validation = validateLongitude(value); + if (!validation.isValid) { + return ParseResult.error(validation.errorMessage!); + } + + return ParseResult.success(value); + } + + /// Parses and validates frequency string (in MHz) + /// + /// Returns ParseResult with parsed value or error message. + ParseResult parseFrequency(String text) { + if (text.trim().isEmpty) { + return const ParseResult.error('Frequency is required'); + } + + final value = double.tryParse(text.trim()); + if (value == null) { + return const ParseResult.error('Invalid number format'); + } + + final validation = validateFrequency(value); + if (!validation.isValid) { + return ParseResult.error(validation.errorMessage!); + } + + return ParseResult.success(value); + } + + /// Parses and validates TX power string (in dBm) + /// + /// Returns ParseResult with parsed value or error message. + ParseResult parseTxPower(String text, {int? maxPower}) { + if (text.trim().isEmpty) { + return const ParseResult.error('TX power is required'); + } + + final value = int.tryParse(text.trim()); + if (value == null) { + return const ParseResult.error('Invalid number format'); + } + + final validation = validateTxPower(value, maxPower); + if (!validation.isValid) { + return ParseResult.error(validation.errorMessage!); + } + + return ParseResult.success(value); + } + + // ============================================================================ + // SANITIZATION METHODS + // ============================================================================ + + /// Sanitizes name string + /// + /// - Trims whitespace + /// - Removes control characters + /// - Truncates to maxLength if specified (defaults to 32) + String sanitizeName(String name, {int? maxLength}) { + final max = maxLength ?? 32; + + // Trim whitespace + String sanitized = name.trim(); + + // Remove control characters (0x00-0x1F, 0x7F) + sanitized = sanitized.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), ''); + + // Truncate if too long + if (sanitized.length > max) { + sanitized = sanitized.substring(0, max); + } + + return sanitized; + } + + /// Sanitizes password string + /// + /// - Removes whitespace + /// - Removes control characters + /// - Truncates to 15 characters (MeshCore protocol limit) + String sanitizePassword(String password) { + // Remove all whitespace + String sanitized = password.replaceAll(RegExp(r'\s'), ''); + + // Remove control characters + sanitized = sanitized.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), ''); + + // Truncate to protocol limit + if (sanitized.length > 15) { + sanitized = sanitized.substring(0, 15); + } + + return sanitized; + } +} + +// ============================================================================== +// RESULT CLASSES +// ============================================================================== + +/// Result of a validation operation +/// +/// Contains either success (isValid=true) or failure with error message. +class ValidationResult { + /// Whether the validation passed + final bool isValid; + + /// Error message if validation failed (null if valid) + final String? errorMessage; + + /// Creates a valid result + const ValidationResult.valid() + : isValid = true, + errorMessage = null; + + /// Creates an invalid result with error message + const ValidationResult.invalid(this.errorMessage) : isValid = false; + + @override + String toString() { + return isValid ? 'Valid' : 'Invalid: $errorMessage'; + } +} + +/// Result of a parse operation +/// +/// Contains either parsed value (success) or error message (failure). +class ParseResult { + /// Parsed value if successful (null if error) + final T? value; + + /// Error message if parsing failed (null if successful) + final String? errorMessage; + + /// Creates a successful parse result + const ParseResult.success(this.value) : errorMessage = null; + + /// Creates a failed parse result with error message + const ParseResult.error(this.errorMessage) : value = null; + + /// Whether the parse operation succeeded + bool get isSuccess => value != null; + + @override + String toString() { + return isSuccess ? 'Success: $value' : 'Error: $errorMessage'; + } +} diff --git a/lib/services/voice_bitrate_preferences.dart b/lib/services/voice_bitrate_preferences.dart new file mode 100644 index 0000000..694fe63 --- /dev/null +++ b/lib/services/voice_bitrate_preferences.dart @@ -0,0 +1,52 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'profiles_feature_service.dart'; +import '../utils/voice_message_parser.dart'; + +/// Stores user-selected voice bitrate and maps it to supported codec modes. +class VoiceBitratePreferences { + static const String _bitrateKey = 'voice_bitrate'; + static const int defaultBitrate = 1300; + static const List supportedBitrates = [ + 700, + 1200, + 1300, + 1400, + 1600, + 2400, + 3200, + ]; + + static Future getBitrate() async { + final prefs = await SharedPreferences.getInstance(); + final value = + prefs.getInt(ProfileStorageScope.scopedKey(_bitrateKey)) ?? + defaultBitrate; + return supportedBitrates.contains(value) ? value : defaultBitrate; + } + + static Future setBitrate(int bitrate) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(ProfileStorageScope.scopedKey(_bitrateKey), bitrate); + } + + static VoicePacketMode toVoiceMode(int bitrate) { + switch (bitrate) { + case 1200: + return VoicePacketMode.mode1200; + case 1300: + return VoicePacketMode.mode1300; + case 1400: + return VoicePacketMode.mode1400; + case 1600: + return VoicePacketMode.mode1600; + case 2400: + return VoicePacketMode.mode2400; + case 3200: + return VoicePacketMode.mode3200; + case 700: + return VoicePacketMode.mode700c; + default: + return VoicePacketMode.mode1300; + } + } +} diff --git a/lib/services/voice_codec_service.dart b/lib/services/voice_codec_service.dart new file mode 100644 index 0000000..fedbd4f --- /dev/null +++ b/lib/services/voice_codec_service.dart @@ -0,0 +1,2 @@ +export 'voice_codec_service_stub.dart' + if (dart.library.io) 'voice_codec_service_io.dart'; diff --git a/lib/services/voice_codec_service_io.dart b/lib/services/voice_codec_service_io.dart new file mode 100644 index 0000000..2a240af --- /dev/null +++ b/lib/services/voice_codec_service_io.dart @@ -0,0 +1,80 @@ +import 'dart:typed_data'; +import 'package:codec2_flutter/codec2_flutter.dart'; +import 'package:flutter/foundation.dart'; +import '../utils/voice_message_parser.dart'; + +Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { + switch (pktMode) { + case VoicePacketMode.mode3200: + return Codec2Mode.mode3200; + case VoicePacketMode.mode1600: + return Codec2Mode.mode1600; + case VoicePacketMode.mode1400: + return Codec2Mode.mode1400; + case VoicePacketMode.mode700c: + return Codec2Mode.mode700c; + case VoicePacketMode.mode1200: + return Codec2Mode.mode1200; + case VoicePacketMode.mode1300: + return Codec2Mode.mode1300; + case VoicePacketMode.mode2400: + return Codec2Mode.mode2400; + } +} + +/// Selects the [VoicePacketMode] best suited for a given LoRa radio bandwidth. +/// +/// Call with [radioBandwidthHz] from the device's radio params +/// (e.g. 125000 for 125 kHz). +VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) { + if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c; + if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200; + return VoicePacketMode.mode1300; +} + +/// High-level codec service that provides async Codec2 encode/decode +/// executed in a background isolate so the UI thread is never blocked. +class VoiceCodecService { + void _ensureCodec2Supported() { + if (kIsWeb || + (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.android)) { + throw UnsupportedError('Codec2 is enabled only on iOS and Android.'); + } + } + + Future encode(Int16List pcm, VoicePacketMode mode) { + _ensureCodec2Supported(); + return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode)); + } + + Future decode(Uint8List codec2Bytes, VoicePacketMode mode) { + _ensureCodec2Supported(); + return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode)); + } + + /// Decode and concatenate multiple [packets] into a single PCM Int16List. + /// Packets with null/missing entries are substituted with silence. + Future decodePackets( + List packets, + VoicePacketMode mode, + ) async { + _ensureCodec2Supported(); + final all = []; + for (final pkt in packets) { + if (pkt == null || pkt.codec2Data.isEmpty) { + all.add(Int16List(mode.samplesPerPacket)); + } else { + all.add(await decode(pkt.codec2Data, mode)); + } + } + final total = all.fold(0, (sum, l) => sum + l.length); + final result = Int16List(total); + var offset = 0; + for (final chunk in all) { + result.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + return result; + } +} diff --git a/lib/services/voice_codec_service_stub.dart b/lib/services/voice_codec_service_stub.dart new file mode 100644 index 0000000..d2229fe --- /dev/null +++ b/lib/services/voice_codec_service_stub.dart @@ -0,0 +1,83 @@ +import 'dart:typed_data'; +import '../utils/voice_message_parser.dart'; + +enum Codec2Mode { + mode3200(0), + mode2400(1), + mode1600(2), + mode1400(3), + mode1300(4), + mode1200(5), + mode700c(8); + + const Codec2Mode(this.c2ModeId); + final int c2ModeId; + + int get framesPerSecond => (this == mode3200 || this == mode2400) ? 50 : 25; + int get samplesPerFrame => 8000 ~/ framesPerSecond; + + int get bytesPerSecond { + switch (this) { + case mode3200: + return 400; + case mode700c: + return 100; + case mode1200: + return 150; + case mode1300: + return 175; + case mode1400: + return 175; + case mode1600: + return 200; + case mode2400: + return 300; + } + } + + static const int _maxBytesPerPacket = 160; + + int get packetDurationMs { + final bytesPerFrame = bytesPerSecond / framesPerSecond; + final framesPerPacket = (_maxBytesPerPacket / bytesPerFrame).floor(); + return (framesPerPacket * 1000 ~/ framesPerSecond); + } +} + +Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { + switch (pktMode) { + case VoicePacketMode.mode3200: + return Codec2Mode.mode3200; + case VoicePacketMode.mode1600: + return Codec2Mode.mode1600; + case VoicePacketMode.mode1400: + return Codec2Mode.mode1400; + case VoicePacketMode.mode700c: + return Codec2Mode.mode700c; + case VoicePacketMode.mode1200: + return Codec2Mode.mode1200; + case VoicePacketMode.mode1300: + return Codec2Mode.mode1300; + case VoicePacketMode.mode2400: + return Codec2Mode.mode2400; + } +} + +VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) { + if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c; + if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200; + return VoicePacketMode.mode1300; +} + +class VoiceCodecService { + Future encode(Int16List pcm, VoicePacketMode mode) => + Future.error(UnsupportedError('Voice not supported on web')); + + Future decode(Uint8List codec2Bytes, VoicePacketMode mode) => + Future.error(UnsupportedError('Voice not supported on web')); + + Future decodePackets( + List packets, + VoicePacketMode mode, + ) => Future.error(UnsupportedError('Voice not supported on web')); +} diff --git a/lib/services/voice_player_service.dart b/lib/services/voice_player_service.dart new file mode 100644 index 0000000..ec4d2c2 --- /dev/null +++ b/lib/services/voice_player_service.dart @@ -0,0 +1,182 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:async'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Plays decoded mono PCM samples by writing a WAV file +/// to the system temp directory and using [AudioPlayer]. +class VoicePlayerService { + final AudioPlayer _player = AudioPlayer(); + final StreamController _events = StreamController.broadcast(); + bool _isPlaying = false; + bool _isDisposed = false; + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + Timer? _fallbackTicker; + DateTime? _playbackStartedAt; + + bool get isPlaying => _isPlaying; + Duration get position => _position; + Duration get duration => _duration; + Stream get events => _events.stream; + + VoicePlayerService() { + _player.onPlayerStateChanged.listen((state) { + debugPrint('🔊 [VoicePlayer] state → $state'); + _isPlaying = state == PlayerState.playing; + if (_isPlaying) { + _startFallbackTicker(); + } else { + _stopFallbackTicker(); + } + _emit(); + }); + _player.onLog.listen((msg) => debugPrint('🔊 [VoicePlayer] log: $msg')); + _player.onPositionChanged.listen((position) { + _position = position; + _emit(); + }); + _player.onDurationChanged.listen((duration) { + _duration = duration; + _emit(); + }); + _player.onPlayerComplete.listen((_) { + _isPlaying = false; + _position = _duration; + _stopFallbackTicker(); + _emit(); + }); + } + + Future play(Int16List pcmSamples, {required int sampleRateHz}) async { + debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples'); + if (_isPlaying) await stop(); + _position = Duration.zero; + _duration = Duration( + milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz, + ); + _playbackStartedAt = DateTime.now(); + _emit(); + + final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz); + final tmpDir = await getTemporaryDirectory(); + final file = File('${tmpDir.path}/vc_voice.wav'); + await file.writeAsBytes(wavBytes); + debugPrint( + '🔊 [VoicePlayer] WAV written: ${wavBytes.length} bytes → ${file.path}', + ); + + try { + _isPlaying = true; + _startFallbackTicker(); + _emit(); + await _player.play(DeviceFileSource(file.path)); + debugPrint('🔊 [VoicePlayer] play() returned (audio playing)'); + } catch (e, st) { + debugPrint('❌ [VoicePlayer] play() error: $e\n$st'); + _isPlaying = false; + _stopFallbackTicker(); + _emit(); + } + } + + Future stop() async { + debugPrint('🔊 [VoicePlayer] stop()'); + await _player.stop(); + _isPlaying = false; + _position = Duration.zero; + _playbackStartedAt = null; + _stopFallbackTicker(); + _emit(); + } + + void dispose() { + _isDisposed = true; + _stopFallbackTicker(); + _events.close(); + _player.dispose(); + } + + void _emit() { + if (!_isDisposed) { + _events.add(null); + } + } + + void _startFallbackTicker() { + if (_fallbackTicker != null) return; + _fallbackTicker = Timer.periodic(const Duration(milliseconds: 100), (_) { + if (!_isPlaying || _duration.inMilliseconds <= 0) return; + final startedAt = _playbackStartedAt; + if (startedAt == null) return; + final elapsed = DateTime.now().difference(startedAt); + final clamped = elapsed > _duration ? _duration : elapsed; + if (clamped > _position) { + _position = clamped; + _emit(); + } + }); + } + + void _stopFallbackTicker() { + _fallbackTicker?.cancel(); + _fallbackTicker = null; + } + + // ── WAV file builder ───────────────────────────────────────────────────── + + /// Constructs a minimal WAV (RIFF/PCM) file from Int16 mono samples. + static Uint8List _buildWav(Int16List samples, {required int sampleRate}) { + const int numChannels = 1; + const int bitsPerSample = 16; + const int audioFormat = 1; // PCM + + final dataSize = samples.length * 2; // 2 bytes per Int16 sample + final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8; + final blockAlign = numChannels * bitsPerSample ~/ 8; + final totalSize = 36 + dataSize; + + final buf = ByteData(44 + dataSize); + var offset = 0; + + void writeStr(String s) { + for (final c in s.codeUnits) { + buf.setUint8(offset++, c); + } + } + + void writeU32(int v) { + buf.setUint32(offset, v, Endian.little); + offset += 4; + } + + void writeU16(int v) { + buf.setUint16(offset, v, Endian.little); + offset += 2; + } + + writeStr('RIFF'); + writeU32(totalSize); + writeStr('WAVE'); + writeStr('fmt '); + writeU32(16); // subchunk1 size + writeU16(audioFormat); // 1 = PCM + writeU16(numChannels); + writeU32(sampleRate); + writeU32(byteRate); + writeU16(blockAlign); + writeU16(bitsPerSample); + writeStr('data'); + writeU32(dataSize); + + // PCM sample data (little-endian Int16) + for (final s in samples) { + buf.setInt16(offset, s, Endian.little); + offset += 2; + } + + return buf.buffer.asUint8List(); + } +} diff --git a/lib/services/voice_recorder_service.dart b/lib/services/voice_recorder_service.dart new file mode 100644 index 0000000..713c77b --- /dev/null +++ b/lib/services/voice_recorder_service.dart @@ -0,0 +1,396 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:record/record.dart'; + +/// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono. +/// +/// [startCapture] returns a [Stream] that emits chunks of PCM +/// samples every [chunkDuration]. Call [stopCapture] to end recording. +class VoiceRecorderService { + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _sub; + StreamController? _controller; + + bool _isRecording = false; + bool get isRecording => _isRecording; + + /// Request microphone permission. Returns true if granted. + Future requestPermission() async { + return _recorder.hasPermission(request: true); + } + + /// Start capturing PCM audio. + /// + /// [chunkDuration] controls how often samples are emitted (default 1 s). + /// [enableBandPassFilter] applies voice-tuned band-pass filtering when true. + /// [enableCompressor] normalizes speech dynamics before encoding. + /// [enableLimiter] protects against clipping peaks before encoding. + /// The returned stream emits [Int16List] chunks that are ready for voice encoding. + Stream startCapture({ + Duration chunkDuration = const Duration(seconds: 1), + int sampleRateHz = 8000, + bool enableBandPassFilter = true, + bool enableCompressor = true, + bool enableLimiter = true, + bool enableAutoGain = false, + bool enableEchoCancellation = false, + bool enableNoiseSuppression = false, + }) { + if (_isRecording) { + throw StateError('VoiceRecorderService: already recording'); + } + + _controller = StreamController(onCancel: () => _stopInternal()); + _isRecording = true; + + _startRecording( + chunkDuration, + sampleRateHz: sampleRateHz, + enableBandPassFilter: enableBandPassFilter, + enableCompressor: enableCompressor, + enableLimiter: enableLimiter, + enableAutoGain: enableAutoGain, + enableEchoCancellation: enableEchoCancellation, + enableNoiseSuppression: enableNoiseSuppression, + ); + return _controller!.stream; + } + + Future _startRecording( + Duration chunkDuration, { + required int sampleRateHz, + required bool enableBandPassFilter, + required bool enableCompressor, + required bool enableLimiter, + required bool enableAutoGain, + required bool enableEchoCancellation, + required bool enableNoiseSuppression, + }) async { + final useBandPassFilter = enableBandPassFilter; + final useCompressor = enableCompressor; + final useLimiter = enableLimiter; + final config = RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: sampleRateHz, + numChannels: 1, + bitRate: 128000, // ignored for PCM, but required by API + autoGain: enableAutoGain, + echoCancel: enableEchoCancellation, + noiseSuppress: enableNoiseSuppression, + ); + + try { + final stream = await _recorder.startStream(config); + final voiceFilter = _VoiceBandPassFilter( + sampleRate: sampleRateHz, + lowCutHz: 250.0, + highCutHz: 3400.0, + ); + final dynamics = _VoiceDynamicsProcessor( + sampleRate: sampleRateHz, + thresholdDb: -18.0, + ratio: 2.5, + attackMs: 8.0, + releaseMs: 120.0, + makeupGainDb: 4.0, + enableCompressor: useCompressor, + enableLimiter: useLimiter, + ); + final chunkBytes = + sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000; + final buffer = []; + + _sub = stream.listen( + (data) { + buffer.addAll(data); + while (buffer.length >= chunkBytes) { + final chunk = buffer.sublist(0, chunkBytes); + buffer.removeRange(0, chunkBytes); + final pcm = _bytesToInt16(Uint8List.fromList(chunk)); + final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm; + _controller?.add(dynamics.process(filtered)); + } + }, + onDone: () { + if (buffer.isNotEmpty) { + final padded = _padToEven(buffer); + final pcm = _bytesToInt16(Uint8List.fromList(padded)); + final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm; + _controller?.add(dynamics.process(filtered)); + } + _controller?.close(); + }, + onError: (e) { + debugPrint('❌ [VoiceRecorder] Stream error: $e'); + _controller?.addError(e); + _controller?.close(); + }, + cancelOnError: false, + ); + } catch (e) { + debugPrint('❌ [VoiceRecorder] Failed to start: $e'); + _isRecording = false; + _controller?.addError(e); + _controller?.close(); + } + } + + /// Stop recording and flush remaining samples. + Future stopCapture() async { + await _stopInternal(); + } + + Future _stopInternal() async { + if (!_isRecording) return; + _isRecording = false; + await _sub?.cancel(); + _sub = null; + await _recorder.stop(); + } + + void dispose() { + _stopInternal(); + _recorder.dispose(); + } + + /// Convert raw little-endian PCM bytes to Int16List. + static Int16List _bytesToInt16(Uint8List bytes) { + final bd = ByteData.sublistView(bytes); + final samples = Int16List(bytes.length ~/ 2); + for (var i = 0; i < samples.length; i++) { + samples[i] = bd.getInt16(i * 2, Endian.little); + } + return samples; + } + + static List _padToEven(List buf) { + if (buf.length % 2 != 0) buf.add(0); + return buf; + } +} + +/// Light speech-focused dynamics processing. +/// +/// Compressor improves low-level intelligibility; limiter prevents peaks that +/// can create harsh codec artifacts. +class _VoiceDynamicsProcessor { + final bool _enableCompressor; + final bool _enableLimiter; + final _SimpleCompressor _compressor; + final _PeakLimiter _limiter; + + _VoiceDynamicsProcessor({ + required int sampleRate, + required double thresholdDb, + required double ratio, + required double attackMs, + required double releaseMs, + required double makeupGainDb, + required bool enableCompressor, + required bool enableLimiter, + }) : _enableCompressor = enableCompressor, + _enableLimiter = enableLimiter, + _compressor = _SimpleCompressor( + sampleRate: sampleRate.toDouble(), + thresholdDb: thresholdDb, + ratio: ratio, + attackMs: attackMs, + releaseMs: releaseMs, + makeupGainDb: makeupGainDb, + ), + _limiter = _PeakLimiter(ceilingDb: -1.0); + + Int16List process(Int16List input) { + final output = Int16List(input.length); + for (var i = 0; i < input.length; i++) { + var sample = input[i].toDouble(); + if (_enableCompressor) { + sample = _compressor.process(sample); + } + if (_enableLimiter) { + sample = _limiter.process(sample); + } + output[i] = sample.clamp(-32768.0, 32767.0).round(); + } + return output; + } +} + +/// Basic feed-forward compressor with attack/release smoothing. +class _SimpleCompressor { + final double _thresholdDb; + final double _ratio; + final double _makeupGain; + final double _attackCoeff; + final double _releaseCoeff; + static const double _eps = 1.0; + + double _env = 0.0; + double _gain = 1.0; + + _SimpleCompressor({ + required double sampleRate, + required double thresholdDb, + required double ratio, + required double attackMs, + required double releaseMs, + required double makeupGainDb, + }) : _thresholdDb = thresholdDb, + _ratio = ratio, + _makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(), + _attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))), + _releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0))); + + double process(double x) { + final absX = x.abs(); + final envCoeff = absX > _env ? _attackCoeff : _releaseCoeff; + _env = envCoeff * _env + (1.0 - envCoeff) * absX; + + final envDb = 20.0 * math.log((_env + _eps) / 32768.0) / math.ln10; + var targetGain = 1.0; + if (envDb > _thresholdDb) { + final outDb = _thresholdDb + (envDb - _thresholdDb) / _ratio; + final gainDb = outDb - envDb; + targetGain = math.pow(10.0, gainDb / 20.0).toDouble(); + } + targetGain *= _makeupGain; + + final gainCoeff = targetGain < _gain ? _attackCoeff : _releaseCoeff; + _gain = gainCoeff * _gain + (1.0 - gainCoeff) * targetGain; + return x * _gain; + } +} + +/// Hard peak limiter with fixed ceiling. +class _PeakLimiter { + final double _ceiling; + + _PeakLimiter({required double ceilingDb}) + : _ceiling = 32767.0 * math.pow(10.0, ceilingDb / 20.0).toDouble(); + + double process(double x) { + if (x > _ceiling) return _ceiling; + if (x < -_ceiling) return -_ceiling; + return x; + } +} + +/// Band-pass filter tuned for human voice at 8 kHz input. +/// +/// Uses a cascaded high-pass + low-pass biquad to attenuate very low-frequency +/// rumble and high-frequency noise outside the speech band. +class _VoiceBandPassFilter { + final _BiquadFilter _highPass; + final _BiquadFilter _lowPass; + + _VoiceBandPassFilter({ + required int sampleRate, + required double lowCutHz, + required double highCutHz, + }) : _highPass = _BiquadFilter.highPass( + sampleRate: sampleRate.toDouble(), + cutoffHz: lowCutHz, + ), + _lowPass = _BiquadFilter.lowPass( + sampleRate: sampleRate.toDouble(), + cutoffHz: highCutHz, + ); + + Int16List process(Int16List input) { + final output = Int16List(input.length); + for (var i = 0; i < input.length; i++) { + var sample = input[i].toDouble(); + sample = _highPass.process(sample); + sample = _lowPass.process(sample); + output[i] = sample.clamp(-32768.0, 32767.0).round(); + } + return output; + } +} + +/// Standard biquad IIR filter (Direct Form I). +class _BiquadFilter { + final double _b0; + final double _b1; + final double _b2; + final double _a1; + final double _a2; + + double _x1 = 0.0; + double _x2 = 0.0; + double _y1 = 0.0; + double _y2 = 0.0; + + _BiquadFilter._({ + required double b0, + required double b1, + required double b2, + required double a1, + required double a2, + }) : _b0 = b0, + _b1 = b1, + _b2 = b2, + _a1 = a1, + _a2 = a2; + + factory _BiquadFilter.lowPass({ + required double sampleRate, + required double cutoffHz, + }) { + const q = math.sqrt1_2; // Butterworth response (Q = 1/sqrt(2)) + final omega = 2.0 * math.pi * cutoffHz / sampleRate; + final cosOmega = math.cos(omega); + final alpha = math.sin(omega) / (2.0 * q); + + final b0 = (1.0 - cosOmega) / 2.0; + final b1 = 1.0 - cosOmega; + final b2 = (1.0 - cosOmega) / 2.0; + final a0 = 1.0 + alpha; + final a1 = -2.0 * cosOmega; + final a2 = 1.0 - alpha; + + return _BiquadFilter._( + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + ); + } + + factory _BiquadFilter.highPass({ + required double sampleRate, + required double cutoffHz, + }) { + const q = math.sqrt1_2; // Butterworth response (Q = 1/sqrt(2)) + final omega = 2.0 * math.pi * cutoffHz / sampleRate; + final cosOmega = math.cos(omega); + final alpha = math.sin(omega) / (2.0 * q); + + final b0 = (1.0 + cosOmega) / 2.0; + final b1 = -(1.0 + cosOmega); + final b2 = (1.0 + cosOmega) / 2.0; + final a0 = 1.0 + alpha; + final a1 = -2.0 * cosOmega; + final a2 = 1.0 - alpha; + + return _BiquadFilter._( + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + ); + } + + double process(double x) { + final y = _b0 * x + _b1 * _x1 + _b2 * _x2 - _a1 * _y1 - _a2 * _y2; + _x2 = _x1; + _x1 = x; + _y2 = _y1; + _y1 = y; + return y; + } +} diff --git a/lib/services/wizard_preferences.dart b/lib/services/wizard_preferences.dart new file mode 100644 index 0000000..a5e1825 --- /dev/null +++ b/lib/services/wizard_preferences.dart @@ -0,0 +1,42 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Service to manage welcome wizard preferences and state +class WizardPreferences { + static const String _wizardCompletedKey = 'wizard_completed'; + static const String _wizardVersionKey = 'wizard_version'; + static const int _currentWizardVersion = 1; + + /// Check if the welcome wizard has been completed + static Future isWizardCompleted() async { + final prefs = await SharedPreferences.getInstance(); + final completed = prefs.getBool(_wizardCompletedKey) ?? false; + final version = prefs.getInt(_wizardVersionKey) ?? 0; + + // Re-show wizard if version has changed (for major updates) + return completed && version >= _currentWizardVersion; + } + + /// Mark the welcome wizard as completed + static Future setWizardCompleted(bool completed) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_wizardCompletedKey, completed); + + if (completed) { + // Store current version when wizard is completed + await prefs.setInt(_wizardVersionKey, _currentWizardVersion); + } + } + + /// Get the wizard version last shown to the user + static Future getWizardVersion() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_wizardVersionKey) ?? 0; + } + + /// Reset wizard state (useful for testing or re-showing tutorial) + static Future resetWizard() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_wizardCompletedKey, false); + await prefs.setInt(_wizardVersionKey, 0); + } +} diff --git a/lib/services/wms_tile_provider.dart b/lib/services/wms_tile_provider.dart new file mode 100644 index 0000000..47b5ac5 --- /dev/null +++ b/lib/services/wms_tile_provider.dart @@ -0,0 +1,73 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:http/http.dart' as http; + +/// Custom tile provider that logs WMS URLs for debugging +class DebugWmsTileProvider extends TileProvider { + final http.Client httpClient; + + DebugWmsTileProvider() : httpClient = http.Client(); + + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) { + return DebugNetworkTileProvider( + coordinates: coordinates, + options: options, + httpClient: httpClient, + ); + } + + @override + void dispose() { + httpClient.close(); + super.dispose(); + } +} + +class DebugNetworkTileProvider extends ImageProvider { + final TileCoordinates coordinates; + final TileLayer options; + final http.Client httpClient; + + const DebugNetworkTileProvider({ + required this.coordinates, + required this.options, + required this.httpClient, + }); + + @override + ImageStreamCompleter loadImage(DebugNetworkTileProvider key, ImageDecoderCallback decode) { + // Get the WMS URL from the tile layer options + final wmsOptions = options.wmsOptions; + if (wmsOptions == null) { + throw Exception('WMSTileLayerOptions is required for DebugWmsTileProvider'); + } + + // Build the WMS URL + final url = wmsOptions.getUrl(coordinates, 256, false); + + // Log the URL for debugging + debugPrint('🌐 WMS Request URL: $url'); + + // Use NetworkImage to load the tile + return NetworkImage(url, headers: {'User-Agent': 'MeshCore SAR'}) + .loadImage(NetworkImage(url), decode); + } + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is DebugNetworkTileProvider && + other.coordinates == coordinates && + other.options == options; + } + + @override + int get hashCode => Object.hash(coordinates, options); +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..b63b1cf --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; + +enum AppThemeMode { light, dark, sarRed, sarGreen, sarNavyBlue, system } + +class AppTheme { + static ThemeData _withSharedControls(ThemeData theme) { + final colorScheme = theme.colorScheme; + return theme.copyWith( + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colorScheme.onSurface.withValues(alpha: 0.38); + } + if (states.contains(WidgetState.selected)) { + return colorScheme.onPrimary; + } + return colorScheme.outline; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colorScheme.onSurface.withValues(alpha: 0.12); + } + if (states.contains(WidgetState.selected)) { + return colorScheme.primary; + } + return colorScheme.surfaceContainerHighest; + }), + trackOutlineColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colorScheme.primary; + } + return colorScheme.outlineVariant; + }), + ), + ); + } + + // Light theme (Blue) + static ThemeData get lightTheme { + return _withSharedControls( + ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.light, + ), + appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + filled: true, + ), + ), + ); + } + + // Dark theme (Blue) + static ThemeData get darkTheme { + return _withSharedControls( + ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.dark, + ), + appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + filled: true, + ), + ), + ); + } + + // SAR Red theme (Emergency/Alert tones) + static ThemeData get sarRedTheme { + return _withSharedControls( + ThemeData( + useMaterial3: true, + colorScheme: const ColorScheme.dark( + brightness: Brightness.dark, + primary: Color(0xFFFF5252), // Bright red + onPrimary: Color(0xFF000000), + primaryContainer: Color(0xFF8B0000), // Dark red + onPrimaryContainer: Color(0xFFFFCDD2), + secondary: Color(0xFFFF8A80), + onSecondary: Color(0xFF000000), + secondaryContainer: Color(0xFFB71C1C), + onSecondaryContainer: Color(0xFFFFCDD2), + tertiary: Color(0xFFFF6E40), + onTertiary: Color(0xFF000000), + error: Color(0xFFCF6679), + onError: Color(0xFF000000), + surface: Color(0xFF1A0000), // Very dark red-tinted + onSurface: Color(0xFFFFEBEE), + surfaceContainerHighest: Color(0xFF2D0000), + onSurfaceVariant: Color(0xFFFFCDD2), + outline: Color(0xFFFF5252), + ), + scaffoldBackgroundColor: const Color(0xFF1A0000), + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + backgroundColor: Color(0xFF2D0000), + foregroundColor: Color(0xFFFFEBEE), + ), + cardTheme: CardThemeData( + elevation: 2, + color: const Color(0xFF2D0000), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFFFF5252), width: 1), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFFF5252)), + ), + filled: true, + fillColor: const Color(0xFF2D0000), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF5252), + foregroundColor: const Color(0xFF000000), + ), + ), + ), + ); + } + + // SAR Green theme (All Clear/Safe tones) + static ThemeData get sarGreenTheme { + return _withSharedControls( + ThemeData( + useMaterial3: true, + colorScheme: const ColorScheme.dark( + brightness: Brightness.dark, + primary: Color(0xFF69F0AE), // Bright green + onPrimary: Color(0xFF000000), + primaryContainer: Color(0xFF00695C), // Dark teal-green + onPrimaryContainer: Color(0xFFB9F6CA), + secondary: Color(0xFF64FFDA), + onSecondary: Color(0xFF000000), + secondaryContainer: Color(0xFF004D40), + onSecondaryContainer: Color(0xFFB9F6CA), + tertiary: Color(0xFF1DE9B6), + onTertiary: Color(0xFF000000), + error: Color(0xFFCF6679), + onError: Color(0xFF000000), + surface: Color(0xFF001A12), // Very dark green-tinted + onSurface: Color(0xFFE8F5E9), + surfaceContainerHighest: Color(0xFF002D1F), + onSurfaceVariant: Color(0xFFB9F6CA), + outline: Color(0xFF69F0AE), + ), + scaffoldBackgroundColor: const Color(0xFF001A12), + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + backgroundColor: Color(0xFF002D1F), + foregroundColor: Color(0xFFE8F5E9), + ), + cardTheme: CardThemeData( + elevation: 2, + color: const Color(0xFF002D1F), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFF69F0AE), width: 1), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF69F0AE)), + ), + filled: true, + fillColor: const Color(0xFF002D1F), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF69F0AE), + foregroundColor: const Color(0xFF000000), + ), + ), + ), + ); + } + + // SAR Navy Blue theme (Professional/Operations tones) + static ThemeData get sarNavyBlueTheme { + return _withSharedControls( + ThemeData( + useMaterial3: true, + colorScheme: const ColorScheme.dark( + brightness: Brightness.dark, + primary: Color(0xFF5C9FFF), // Bright navy blue + onPrimary: Color(0xFF000000), + primaryContainer: Color(0xFF003366), // Dark navy + onPrimaryContainer: Color(0xFFBBDEFF), + secondary: Color(0xFF80B3FF), + onSecondary: Color(0xFF000000), + secondaryContainer: Color(0xFF002244), + onSecondaryContainer: Color(0xFFBBDEFF), + tertiary: Color(0xFF4DB8FF), + onTertiary: Color(0xFF000000), + error: Color(0xFFCF6679), + onError: Color(0xFF000000), + surface: Color(0xFF00111C), // Very dark blue-tinted + onSurface: Color(0xFFE3F2FD), + surfaceContainerHighest: Color(0xFF001A2D), + onSurfaceVariant: Color(0xFFBBDEFF), + outline: Color(0xFF5C9FFF), + ), + scaffoldBackgroundColor: const Color(0xFF00111C), + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + backgroundColor: Color(0xFF001A2D), + foregroundColor: Color(0xFFE3F2FD), + ), + cardTheme: CardThemeData( + elevation: 2, + color: const Color(0xFF001A2D), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFF5C9FFF), width: 1), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF5C9FFF)), + ), + filled: true, + fillColor: const Color(0xFF001A2D), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF5C9FFF), + foregroundColor: const Color(0xFF000000), + ), + ), + ), + ); + } + + // Get theme by mode + static ThemeData getTheme(AppThemeMode mode, Brightness systemBrightness) { + switch (mode) { + case AppThemeMode.light: + return lightTheme; + case AppThemeMode.dark: + return darkTheme; + case AppThemeMode.sarRed: + return sarRedTheme; + case AppThemeMode.sarGreen: + return sarGreenTheme; + case AppThemeMode.sarNavyBlue: + return sarNavyBlueTheme; + case AppThemeMode.system: + return systemBrightness == Brightness.dark ? darkTheme : lightTheme; + } + } + + // Get display name for theme mode + static String getThemeDisplayName(AppThemeMode mode) { + switch (mode) { + case AppThemeMode.light: + return 'Light'; + case AppThemeMode.dark: + return 'Dark'; + case AppThemeMode.sarRed: + return 'SAR Red (Alert)'; + case AppThemeMode.sarGreen: + return 'SAR Green (Safe)'; + case AppThemeMode.sarNavyBlue: + return 'SAR Navy Blue (Ops)'; + case AppThemeMode.system: + return 'Auto (System)'; + } + } + + // Get theme mode from string + static AppThemeMode themeFromString(String themeName) { + return AppThemeMode.values.firstWhere( + (mode) => mode.name == themeName, + orElse: () => AppThemeMode.system, + ); + } +} diff --git a/lib/utils/advert_helper.dart b/lib/utils/advert_helper.dart new file mode 100644 index 0000000..729d21f --- /dev/null +++ b/lib/utils/advert_helper.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:provider/provider.dart'; +import 'package:vibration/vibration.dart'; +import '../l10n/app_localizations.dart'; +import '../providers/connection_provider.dart'; +import 'toast_logger.dart'; + +enum AdvertMode { flood, direct } + +Future triggerAdvertFeedback(BuildContext context) async { + final platform = Theme.of(context).platform; + + try { + if (platform == TargetPlatform.iOS) { + await HapticFeedback.lightImpact(); + await Future.delayed(const Duration(milliseconds: 50)); + await HapticFeedback.lightImpact(); + } else { + if (await Vibration.hasVibrator()) { + await Vibration.vibrate(duration: 50); + } else { + await HapticFeedback.mediumImpact(); + } + } + } catch (e) { + debugPrint('Haptic feedback error: $e'); + await HapticFeedback.vibrate(); + } +} + +Future showAdvertModeSheet(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.advertMode, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + l10n.advertModeSubtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.hub_rounded, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + title: Text(l10n.flood), + subtitle: Text(l10n.relayThroughRepeatersAcrossTheMesh), + trailing: const Icon(Icons.chevron_right_rounded), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + onTap: () => Navigator.of(context).pop(AdvertMode.flood), + ), + const SizedBox(height: 8), + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.near_me_rounded, + color: theme.colorScheme.onSecondaryContainer, + ), + ), + title: Text(l10n.direct), + subtitle: Text(l10n.nearbyOnlyWithoutRepeaterFlooding), + trailing: const Icon(Icons.chevron_right_rounded), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + onTap: () => Navigator.of(context).pop(AdvertMode.direct), + ), + ], + ), + ), + ), + ); +} + +Future advertiseDevice( + BuildContext context, { + bool floodMode = true, +}) async { + final connectionProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.deviceNotConnected, + ); + } + return; + } + + try { + // Check if location services are enabled + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.locationServicesDisabled, + ); + } + return; + } + + // Check location permissions + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.locationPermissionDenied, + ); + } + return; + } + } + + if (permission == LocationPermission.deniedForever) { + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.locationPermissionPermanentlyDenied, + ); + } + return; + } + + // Get current GPS position + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 0, + ), + ).timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('❌ Failed to get GPS position: $e'); + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToGetGpsLocation, + ); + } + return; + } + + // Update lat/lon on device + await connectionProvider.setAdvertLatLon( + latitude: position.latitude, + longitude: position.longitude, + ); + + // Small delay to ensure the lat/lon is set + await Future.delayed(const Duration(milliseconds: 100)); + + await connectionProvider.sendSelfAdvert(floodMode: floodMode); + + if (context.mounted) { + final l10n = AppLocalizations.of(context)!; + ToastLogger.success( + context, + floodMode ? l10n.floodAdvertSent : l10n.directAdvertSent, + ); + } + } catch (e) { + debugPrint('❌ Failed to advertise device: $e'); + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToAdvertise(e.toString()), + ); + } + } +} diff --git a/lib/utils/avatar_label_helper.dart b/lib/utils/avatar_label_helper.dart new file mode 100644 index 0000000..2687173 --- /dev/null +++ b/lib/utils/avatar_label_helper.dart @@ -0,0 +1,39 @@ +class AvatarLabelHelper { + static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+'); + + static String buildLabel(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return '?'; + + if (trimmed.startsWith('#')) { + final hashBody = trimmed.substring(1).replaceAll(RegExp(r'[\s_-]+'), ''); + if (hashBody.isEmpty) { + return '#'; + } + return '#${_take(hashBody, 2)}'.toUpperCase(); + } + + final parts = _alnumChunks + .allMatches(trimmed) + .map((match) => match.group(0)!) + .where((part) => part.isNotEmpty) + .toList(); + + if (parts.isEmpty) { + return '?'; + } + + if (parts.length >= 2) { + final first = _take(parts[0], 1); + final second = _take(parts[1], 1); + return '$first$second'.toUpperCase(); + } + + return _take(parts.first, 2).toUpperCase(); + } + + static String _take(String value, int count) { + if (value.length <= count) return value; + return value.substring(0, count); + } +} diff --git a/lib/utils/battery_display_helper.dart b/lib/utils/battery_display_helper.dart new file mode 100644 index 0000000..0e7e2b4 --- /dev/null +++ b/lib/utils/battery_display_helper.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +/// Utility class for battery and signal display helpers +/// Used across home_screen.dart and contact_tile.dart +class BatteryDisplayHelper { + /// Get battery icon based on percentage level + static IconData getBatteryIcon(double percentage) { + if (percentage > 80) return Icons.battery_full; + if (percentage > 50) return Icons.battery_5_bar; + if (percentage > 20) return Icons.battery_3_bar; + return Icons.battery_1_bar; + } + + /// Get battery color based on percentage level + static Color getBatteryColor(double percentage) { + if (percentage > 50) return Colors.green; + if (percentage > 20) return Colors.orange; + return Colors.red; + } + + /// Get signal strength color based on RSSI value + static Color getSignalColor(int rssi) { + if (rssi > -60) return Colors.green; + if (rssi > -70) return Colors.orange; + return Colors.red; + } + + /// Number of active signal bars (0-5) for a BLE RSSI value. + static int getSignalBars(int rssi) { + if (rssi > -50) return 5; + if (rssi > -60) return 4; + if (rssi > -70) return 3; + if (rssi > -80) return 2; + if (rssi > -90) return 1; + return 0; + } +} diff --git a/lib/utils/contact_grouping.dart b/lib/utils/contact_grouping.dart new file mode 100644 index 0000000..875102c --- /dev/null +++ b/lib/utils/contact_grouping.dart @@ -0,0 +1,174 @@ +import '../models/contact.dart'; + +class InferredContactGroup { + final String key; + final String label; + final List contacts; + final List matchPrefixes; + + const InferredContactGroup({ + required this.key, + required this.label, + required this.contacts, + List? matchPrefixes, + }) : matchPrefixes = matchPrefixes ?? const []; + + DateTime get latestSeen => contacts.first.lastSeenTime; +} + +class ContactGrouping { + static final RegExp _prefixedNamePattern = RegExp( + r'^([A-Za-z0-9]{2,})([-_/:])', + ); + + static String? inferredGroupLabelForContact(Contact contact) { + return _extractPrefix(contact.displayName)?.label; + } + + static bool contactMatchesInferredGroupLabel(Contact contact, String query) { + final normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.isEmpty) { + return false; + } + + final groupLabel = inferredGroupLabelForContact(contact)?.toLowerCase(); + return groupLabel?.contains(normalizedQuery) ?? false; + } + + static String? sharedInferredGroupLabel(List contacts) { + String? sharedLabel; + for (final contact in contacts) { + final label = inferredGroupLabelForContact(contact); + if (label == null) { + return null; + } + if (sharedLabel == null) { + sharedLabel = label; + continue; + } + if (sharedLabel != label) { + return null; + } + } + return sharedLabel; + } + + static String? sharedParentGroupLabel(List labels) { + if (labels.isEmpty) { + return null; + } + + var commonPrefix = labels.first; + for (final label in labels.skip(1)) { + final maxLength = commonPrefix.length < label.length + ? commonPrefix.length + : label.length; + var matchLength = 0; + while (matchLength < maxLength && + commonPrefix.codeUnitAt(matchLength) == + label.codeUnitAt(matchLength)) { + matchLength++; + } + commonPrefix = commonPrefix.substring(0, matchLength); + if (commonPrefix.isEmpty) { + return null; + } + } + + final separatorIndex = commonPrefix.lastIndexOf(RegExp(r'[-_/:]')); + if (separatorIndex < 1) { + return null; + } + + final parentLabel = commonPrefix.substring(0, separatorIndex + 1); + return parentLabel.length >= 3 ? parentLabel : null; + } + + static List sortByLastSeen(List contacts) { + return List.from(contacts) + ..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); + } + + static List inferGroups( + List contacts, { + int minGroupSize = 4, + int? maxNamedGroups, + String? overflowGroupLabel, + }) { + final sortedContacts = sortByLastSeen(contacts); + final groupedContacts = >{}; + final groupLabels = {}; + + for (final contact in sortedContacts) { + final prefix = _extractPrefix(contact.displayName); + if (prefix == null) continue; + groupedContacts.putIfAbsent(prefix.key, () => []).add(contact); + groupLabels.putIfAbsent(prefix.key, () => prefix.label); + } + + final rankedGroups = []; + for (final entry in groupedContacts.entries) { + if (entry.value.length < minGroupSize) continue; + rankedGroups.add( + InferredContactGroup( + key: entry.key, + label: groupLabels[entry.key] ?? entry.key, + contacts: entry.value, + matchPrefixes: [groupLabels[entry.key] ?? entry.key], + ), + ); + } + rankedGroups.sort((a, b) => b.latestSeen.compareTo(a.latestSeen)); + + if (maxNamedGroups != null && + overflowGroupLabel != null && + rankedGroups.length > maxNamedGroups) { + final retainedGroups = rankedGroups.take(maxNamedGroups).toList(); + final overflowGroups = rankedGroups.skip(maxNamedGroups).toList(); + final parentLabel = sharedParentGroupLabel([ + for (final group in overflowGroups) ...group.matchPrefixes, + ]); + if (parentLabel != null) { + final overflowContacts = overflowGroups + .expand((group) => group.contacts) + .toList(); + return [ + ...retainedGroups, + InferredContactGroup( + key: parentLabel, + label: parentLabel, + contacts: sortByLastSeen(overflowContacts), + matchPrefixes: [parentLabel], + ), + ]; + } + + return rankedGroups; + } + return rankedGroups; + } + + static _GroupPrefix? _extractPrefix(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return null; + + final match = _prefixedNamePattern.firstMatch(trimmed); + if (match == null) return null; + + final rawPrefix = match.group(1); + final separator = match.group(2); + if (rawPrefix == null || separator == null) return null; + + return _GroupPrefix( + key: rawPrefix.toUpperCase(), + label: '$rawPrefix$separator', + ); + } +} + +class _GroupPrefix { + final String key; + final String label; + + const _GroupPrefix({required this.key, required this.label}); +} diff --git a/lib/utils/contact_sorting.dart b/lib/utils/contact_sorting.dart new file mode 100644 index 0000000..c77b5e9 --- /dev/null +++ b/lib/utils/contact_sorting.dart @@ -0,0 +1,24 @@ +import '../models/contact.dart'; + +int compareContactsByLastSeen(Contact a, Contact b) { + return b.lastSeenTime.compareTo(a.lastSeenTime); +} + +int compareContactsByFavouriteThenLastSeen(Contact a, Contact b) { + if (a.isFavourite != b.isFavourite) { + return a.isFavourite ? -1 : 1; + } + + return compareContactsByLastSeen(a, b); +} + +int compareContactsByDisplayName(Contact a, Contact b) { + final nameCompare = a.displayName.toLowerCase().compareTo( + b.displayName.toLowerCase(), + ); + if (nameCompare != 0) { + return nameCompare; + } + + return a.publicKeyHex.compareTo(b.publicKeyHex); +} diff --git a/lib/utils/custom_map_id.dart b/lib/utils/custom_map_id.dart new file mode 100644 index 0000000..dfcf40c --- /dev/null +++ b/lib/utils/custom_map_id.dart @@ -0,0 +1,18 @@ +const int customMapKey6HexLength = 12; + +String? normalizeCustomMapId(String? mapId) { + if (mapId == null) { + return null; + } + + final normalized = mapId.trim().toLowerCase(); + if (normalized.isEmpty) { + return null; + } + + if (normalized.length <= customMapKey6HexLength) { + return normalized; + } + + return normalized.substring(0, customMapKey6HexLength); +} diff --git a/lib/utils/debug_print.dart b/lib/utils/debug_print.dart new file mode 100644 index 0000000..2034687 --- /dev/null +++ b/lib/utils/debug_print.dart @@ -0,0 +1,8 @@ +import 'package:flutter/foundation.dart'; + +/// Debug print that only outputs in debug builds +void debugPrint(Object? message) { + if (kDebugMode) { + debugPrint(message); + } +} diff --git a/lib/utils/drawing_message_parser.dart b/lib/utils/drawing_message_parser.dart new file mode 100644 index 0000000..603dbcc --- /dev/null +++ b/lib/utils/drawing_message_parser.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; + +import '../models/map_coordinate_space.dart'; +import '../models/map_drawing.dart'; +import 'custom_map_id.dart'; + +class DrawingMessageParser { + static const String legacyPrefix = 'D:'; + static const String customMapPrefix = 'D2:'; + + static bool isDrawingMessage(String text) { + return text.startsWith(legacyPrefix) || text.startsWith(customMapPrefix); + } + + static MapDrawing? parseDrawingMessage( + String text, { + String? senderName, + String? messageId, + }) { + if (!isDrawingMessage(text)) { + return null; + } + + try { + if (text.startsWith(customMapPrefix)) { + final json = + jsonDecode(text.substring(customMapPrefix.length)) + as Map; + final mapId = normalizeCustomMapId(json['m'] as String?); + if (mapId == null || mapId.isEmpty) { + return null; + } + return MapDrawing.fromNetworkJson( + json, + senderName: senderName, + messageId: messageId, + coordinateSpace: MapCoordinateSpace.customMap, + mapId: mapId, + ); + } + + final json = + jsonDecode(text.substring(legacyPrefix.length)) + as Map; + return MapDrawing.fromNetworkJson( + json, + senderName: senderName, + messageId: messageId, + coordinateSpace: MapCoordinateSpace.geo, + ); + } catch (_) { + return null; + } + } + + static String createDrawingMessage(MapDrawing drawing) { + final json = drawing.toNetworkJson(); + final prefix = drawing.coordinateSpace == MapCoordinateSpace.customMap + ? customMapPrefix + : legacyPrefix; + return '$prefix${jsonEncode(json)}'; + } + + static String? getDrawingTypeDisplay(String text) { + final metadata = getDrawingMetadata(text); + return metadata?['type'] as String?; + } + + static String? getColorName(String text) { + final metadata = getDrawingMetadata(text); + return metadata?['color'] as String?; + } + + static Map? getDrawingMetadata(String text) { + if (!isDrawingMessage(text)) return null; + + try { + final json = + jsonDecode( + text.startsWith(customMapPrefix) + ? text.substring(customMapPrefix.length) + : text.substring(legacyPrefix.length), + ) + as Map; + + final typeNum = json['t'] as int?; + final colorIndex = json['c'] as int?; + if (typeNum == null || colorIndex == null) return null; + + String type; + int? pointCount; + switch (typeNum) { + case 0: + type = 'Line'; + final points = json['p'] as List?; + pointCount = points != null ? points.length ~/ 2 : null; + break; + case 1: + type = 'Rectangle'; + pointCount = 4; + break; + default: + return null; + } + + const colorNames = [ + 'Red', + 'Blue', + 'Green', + 'Yellow', + 'Orange', + 'Purple', + 'Pink', + 'Cyan', + ]; + final color = colorIndex >= 0 && colorIndex < colorNames.length + ? colorNames[colorIndex] + : 'Unknown'; + + return { + 'type': type, + 'color': color, + 'pointCount': pointCount, + 'coordinateSpace': text.startsWith(customMapPrefix) + ? MapCoordinateSpace.customMap.name + : MapCoordinateSpace.geo.name, + 'mapId': normalizeCustomMapId(json['m'] as String?), + }; + } catch (_) { + return null; + } + } +} diff --git a/lib/utils/fast_gps_packet.dart b/lib/utils/fast_gps_packet.dart new file mode 100644 index 0000000..0060036 --- /dev/null +++ b/lib/utils/fast_gps_packet.dart @@ -0,0 +1,86 @@ +import 'dart:typed_data'; + +/// Fast-GPS position beacon, wire-compatible with the MeshUI firmware +/// (`maybeSendFastGpsUpdate` in MyMesh.cpp). +/// +/// 16-byte binary payload sent as a `DATA_TYPE_DEV` group datagram on a +/// non-public channel: +/// +/// ``` +/// [0] magic 0x47 ('G') +/// [1..6] sender public-key prefix (6 bytes) +/// [7..10] latitude in microdegrees (int32, little-endian) +/// [11..14] longitude in microdegrees (int32, little-endian) +/// [15] ground speed in km/h (uint8, clamped 0..255) +/// ``` +/// +/// The beacon carries no timestamp — receivers stamp their own RX time. +class FastGpsPacket { + static const int magic = 0x47; // 'G' + static const int _payloadLength = 16; + // Store coordinates in microdegrees. This preserves sub-meter precision, + // which comfortably satisfies the meter-accuracy requirement. + static const double coordinateScale = 1e6; + + final String senderKey6; + final double latitude; + final double longitude; + + /// Ground speed in km/h (0..255), as carried in the beacon's trailing byte. + final int speedKmh; + + const FastGpsPacket({ + required this.senderKey6, + required this.latitude, + required this.longitude, + this.speedKmh = 0, + }); + + static bool isFastGpsBinary(Uint8List payload) => + payload.length == _payloadLength && payload[0] == magic; + + static FastGpsPacket? tryParseBinary(Uint8List payload) { + if (!isFastGpsBinary(payload)) return null; + + final key6 = payload + .sublist(1, 7) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final data = ByteData.sublistView(payload); + final latitude = data.getInt32(7, Endian.little) / coordinateScale; + final longitude = data.getInt32(11, Endian.little) / coordinateScale; + final speedKmh = data.getUint8(15); + + if (!_isValidCoordinate(latitude, longitude)) { + return null; + } + + return FastGpsPacket( + senderKey6: key6, + latitude: latitude, + longitude: longitude, + speedKmh: speedKmh, + ); + } + + Uint8List encodeBinary() { + final out = Uint8List(_payloadLength); + final data = ByteData.sublistView(out); + out[0] = magic; + for (var i = 0; i < 6; i++) { + out[1 + i] = int.parse(senderKey6.substring(i * 2, i * 2 + 2), radix: 16); + } + data.setInt32(7, (latitude * coordinateScale).round(), Endian.little); + data.setInt32(11, (longitude * coordinateScale).round(), Endian.little); + data.setUint8(15, speedKmh.clamp(0, 255)); + return out; + } + + static bool _isValidCoordinate(double latitude, double longitude) { + if (!latitude.isFinite || !longitude.isFinite) return false; + return latitude >= -90.0 && + latitude <= 90.0 && + longitude >= -180.0 && + longitude <= 180.0; + } +} diff --git a/lib/utils/image_message_parser.dart b/lib/utils/image_message_parser.dart new file mode 100644 index 0000000..a0f26ef --- /dev/null +++ b/lib/utils/image_message_parser.dart @@ -0,0 +1,618 @@ +import 'dart:typed_data'; + +const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE +const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen +const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD +const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload +const int _imagePacketHeaderBytes = 6; // image packet binary header in payload +const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10) +const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5) +const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz) +const int _defaultLoRaPreambleSymbols = 8; +const int _defaultLoRaCrcEnabled = 1; +const int _defaultLoRaExplicitHeader = 1; +const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle + +/// Compressed image format used in the image packet protocol. +enum ImageFormat { + avif(0, 'AVIF'), + jpeg(1, 'JPEG'); + + const ImageFormat(this.id, this.label); + final int id; + final String label; + + static ImageFormat fromId(int id) => ImageFormat.values.firstWhere( + (f) => f.id == id, + orElse: () => ImageFormat.avif, + ); +} + +/// A single binary fragment of a compressed image. +/// +/// Binary format (direct contacts, via pushRawData / cmdSendRawData): +/// [0x49 'I'][sessionId:4B][idx:1B][imageData...] +/// +/// Legacy default is 152 data bytes per fragment. +class ImagePacket { + final String sessionId; // 8 hex chars (4 bytes) + final ImageFormat format; + final int index; // 0-based + final int total; // total fragment count (1..255) + final Uint8List data; + + const ImagePacket({ + required this.sessionId, + required this.format, + required this.index, + required this.total, + required this.data, + }); + + static const int _magic = 0x49; // 'I' + static const int _headerLen = 6; // magic(1)+session(4)+idx(1) + static const int maxDataBytes = + 152; // Conservative default for compatibility. + + static bool isImageBinary(Uint8List payload) => + payload.isNotEmpty && payload[0] == _magic; + + static ImagePacket? tryParseBinary(Uint8List payload) { + if (payload.length < _headerLen) return null; + if (payload[0] != _magic) return null; + try { + final sessionId = payload + .sublist(1, 5) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final index = payload[5]; + final data = payload.sublist(_headerLen); + return ImagePacket( + sessionId: sessionId, + format: ImageFormat.avif, + index: index, + total: 0, + data: data, + ); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + final sessionBytes = Uint8List(4); + for (var i = 0; i < 4; i++) { + sessionBytes[i] = int.parse( + sessionId.substring(i * 2, i * 2 + 2), + radix: 16, + ); + } + final out = Uint8List(_headerLen + data.length); + out[0] = _magic; + out.setRange(1, 5, sessionBytes); + out[5] = index; + out.setRange(_headerLen, out.length, data); + return out; + } + + @override + String toString() { + final suffix = total > 0 ? ' ${format.label} [$index/${total - 1}]' : ' [$index]'; + return 'ImagePacket($sessionId$suffix ${data.length}B)'; + } +} + +/// Compute the maximum safe image data bytes for a direct route path. +/// +/// This accounts for: +/// - companion command-frame limit (MAX_FRAME_SIZE=172), +/// - cmdSendRawData overhead (`cmd` + `pathLen`), +/// - image packet binary header (8 bytes), +/// - mesh packet payload limit (MAX_PACKET_PAYLOAD=184). +/// +/// Path length follows Contact.outPathLen semantics: 0 = direct, 1+ = hops. +int safeImageDataBytesForPath(int pathLen) { + final normalizedPathLen = pathLen.clamp(0, 64).toInt(); + final maxRawPayloadFromCommandFrame = + _maxCompanionFrameBytes - + _cmdSendRawDataOverheadBytes - + normalizedPathLen; + final maxRawPayloadFromMesh = + _maxMeshPacketPayloadBytes - ImagePacket._headerLen; + final maxRawPayload = maxRawPayloadFromCommandFrame < maxRawPayloadFromMesh + ? maxRawPayloadFromCommandFrame + : maxRawPayloadFromMesh; + final maxData = maxRawPayload - ImagePacket._headerLen; + // Keep a safety margin below the theoretical direct-route ceiling. + // Fragments at the absolute 172-byte command-frame limit have proven flaky + // in practice, so cap to the conservative protocol default. + return maxData.clamp(1, ImagePacket.maxDataBytes).toInt(); +} + +/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa. +/// +/// The estimate uses: +/// - LoRa airtime math with MeshCore companion defaults (SF10/BW250/CR5), +/// - MeshCore airtime budget pacing (default factor 1.0), +/// - hop multiplier (`pathLen + 1`) for direct routed packets. +Duration estimateImageTransmitDuration({ + required int fragmentCount, + required int sizeBytes, + int pathLen = 0, + int? radioBw, + int? radioSf, + int? radioCr, +}) { + if (fragmentCount <= 0 || sizeBytes <= 0) return Duration.zero; + + final safePathLen = pathLen.clamp(0, 64); + final hops = safePathLen + 1; + final baseDataPerFragment = sizeBytes ~/ fragmentCount; + final extraBytes = sizeBytes % fragmentCount; + var totalMs = 0.0; + + for (var i = 0; i < fragmentCount; i++) { + final fragmentDataBytes = baseDataPerFragment + (i < extraBytes ? 1 : 0); + final loraLen = + _meshPacketHeaderBytes + + safePathLen + + _imagePacketHeaderBytes + + fragmentDataBytes; + final airtimeMs = _estimateLoRaAirtimeMs( + loraLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops; + } + + return Duration(milliseconds: totalMs.round()); +} + +double _estimateLoRaAirtimeMs( + int payloadLenBytes, { + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final sf = _normalizeSf(radioSf); + final bw = _resolveBandwidthHz(radioBw).toDouble(); + final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4); + final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1; + final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0; + + final symbolMs = ((1 << sf) / bw) * 1000.0; + final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs; + + final num = + (8 * payloadLenBytes) - + (4 * sf) + + 28 + + (16 * _defaultLoRaCrcEnabled) - + (20 * ih); + final den = 4 * (sf - (2 * de)); + final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil(); + final payloadSymbols = + 8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4); + final payloadMs = payloadSymbols * symbolMs; + + return preambleMs + payloadMs; +} + +int _normalizeSf(int? value) { + if (value == null) return _defaultLoRaSf; + if (value >= 5 && value <= 12) return value; + return _defaultLoRaSf; +} + +int _normalizeCr(int? value) { + if (value == null) return _defaultLoRaCr; + if (value >= 5 && value <= 8) return value; + return _defaultLoRaCr; +} + +int _resolveBandwidthHz(int? rawBw) { + if (rawBw == null) return _defaultLoRaBwHz; + if (rawBw > 1000) return rawBw; + switch (rawBw) { + case 0: + return 7800; + case 1: + return 10400; + case 2: + return 15600; + case 3: + return 20800; + case 4: + return 31250; + case 5: + return 41700; + case 6: + return 62500; + case 7: + return 125000; + case 8: + return 250000; + case 9: + return 500000; + default: + return _defaultLoRaBwHz; + } +} + +/// Envelope announcing image availability (control plane). +/// +/// Text format: +/// IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes} +/// Example: +/// IE4:deadbeef:0:7:3k:3k:t6 +class ImageEnvelope { + static const String _prefixV4 = 'IE4:'; + + final String sessionId; // 8 hex chars + final ImageFormat format; + final int total; // total fragment count + final int width; + final int height; + final int sizeBytes; // total compressed image size + final int version; + + const ImageEnvelope({ + required this.sessionId, + required this.format, + required this.total, + required this.width, + required this.height, + required this.sizeBytes, + this.version = 4, + }); + + static bool isEnvelope(String text) => text.startsWith(_prefixV4); + + static ImageEnvelope? tryParse(String text) { + if (!isEnvelope(text)) return null; + final body = text.substring(_prefixV4.length); + final parts = body.split(':'); + if (parts.length != 6) return null; + try { + final sid = _decodeSessionId(parts[0]); + final fmtId = _parseInt(parts[1], base36: true); + final total = _parseInt(parts[2], base36: true); + final w = _parseInt(parts[3], base36: true); + final h = _parseInt(parts[4], base36: true); + final bytes = _parseInt(parts[5], base36: true); + + if (sid == null) return null; + if (fmtId == null) return null; + if (total == null || total < 1 || total > 255) return null; + if (w == null || h == null || w < 1 || h < 1) return null; + if (bytes == null || bytes < 1) return null; + + return ImageEnvelope( + sessionId: sid, + format: ImageFormat.fromId(fmtId), + total: total, + width: w, + height: h, + sizeBytes: bytes, + version: 4, + ); + } catch (_) { + return null; + } + } + + String encode() => + '$_prefixV4${_encodeSessionId(sessionId)}:' + '${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:' + '${_toBase36(height)}:${_toBase36(sizeBytes)}'; +} + +/// Direct request to fetch image fragments (control plane). +/// +/// Text format: +/// IR4:{sid}:{want}:{requesterKey6} +/// Example: +/// IR4:deadbeef:a:aabbccddeeff +class ImageFetchRequest { + static const String _prefixV4 = 'IR4:'; + static const int _binaryMagic = 0x69; // 'i' + + final String sessionId; + final String want; // 'all' or 'missing' + final List missingIndices; + final String requesterKey6; // 12 hex chars + final int version; + + const ImageFetchRequest({ + required this.sessionId, + this.want = 'all', + this.missingIndices = const [], + required this.requesterKey6, + this.version = 4, + }); + + static bool isRequest(String text) => text.startsWith(_prefixV4); + static bool isRequestBinary(Uint8List payload) => + payload.isNotEmpty && payload[0] == _binaryMagic; + + static ImageFetchRequest? tryParse(String text) { + if (!isRequest(text)) return null; + final body = text.substring(_prefixV4.length); + final parts = body.split(':'); + if (parts.length != 3) return null; + try { + final sid = _decodeSessionId(parts[0]); + final wantToken = parts[1]; + final requesterKey6 = parts[2]; + final normalizedWant = wantToken == 'a' + ? 'all' + : ((wantToken.startsWith('m')) ? 'missing' : wantToken); + + if (sid == null) return null; + final missingIndices = []; + if (normalizedWant == 'missing') { + final encoded = wantToken.substring(1); + if (encoded.isEmpty) return null; + missingIndices.addAll(_decodeMissingIndicesCompact(encoded)); + if (missingIndices.isEmpty) return null; + } else if (normalizedWant != 'all') { + return null; + } + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null; + + return ImageFetchRequest( + sessionId: sid, + want: normalizedWant, + missingIndices: missingIndices, + requesterKey6: requesterKey6.toLowerCase(), + version: 4, + ); + } catch (_) { + return null; + } + } + + static ImageFetchRequest? tryParseBinary(Uint8List payload) { + if (!isRequestBinary(payload)) return null; + if (payload.length < 13) return null; // magic+sid+flags+key6+count + try { + final sid = payload + .sublist(1, 5) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final flags = payload[5]; + final requesterKey6 = payload + .sublist(6, 12) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final missingCount = payload[12]; + if (payload.length != 13 + missingCount) return null; + final wantMissing = (flags & 0x01) == 0x01; + final missing = []; + for (var i = 0; i < missingCount; i++) { + missing.add(payload[13 + i]); + } + return ImageFetchRequest( + sessionId: sid, + want: wantMissing ? 'missing' : 'all', + missingIndices: missing, + requesterKey6: requesterKey6, + version: 4, + ); + } catch (_) { + return null; + } + } + + String encode() { + final wantToken = want == 'missing' && missingIndices.isNotEmpty + ? 'm${_encodeMissingIndicesCompact(missingIndices)}' + : (want == 'all' ? 'a' : want); + return '$_prefixV4${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}'; + } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) { + throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars'); + } + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { + throw ArgumentError.value( + requesterKey6, + 'requesterKey6', + 'Expected 12 hex chars', + ); + } + final useMissing = want == 'missing' && missingIndices.isNotEmpty; + final missing = useMissing + ? missingIndices.where((v) => v >= 0 && v <= 254).toList() + : []; + + final out = Uint8List(13 + missing.length); + out[0] = _binaryMagic; + for (var i = 0; i < 4; i++) { + out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); + } + out[5] = useMissing ? 0x01 : 0x00; + for (var i = 0; i < 6; i++) { + out[6 + i] = int.parse( + requesterKey6.substring(i * 2, i * 2 + 2), + radix: 16, + ); + } + out[12] = missing.length; + for (var i = 0; i < missing.length; i++) { + out[13 + i] = missing[i]; + } + return out; + } +} + +/// Per-fragment ACK for raw image payload packets. +/// +/// Binary format: +/// [0x6a 'j'][sessionId:4B][index:1B] +class ImageFragmentAck { + static const int _binaryMagic = 0x6a; // 'j' + + final String sessionId; // 8 hex chars + final int index; // 0..254 + + const ImageFragmentAck({required this.sessionId, required this.index}); + + static bool isImageFragmentAckBinary(Uint8List payload) => + payload.length == 6 && payload[0] == _binaryMagic; + + static ImageFragmentAck? tryParseBinary(Uint8List payload) { + if (!isImageFragmentAckBinary(payload)) return null; + try { + final sid = payload + .sublist(1, 5) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final idx = payload[5]; + return ImageFragmentAck(sessionId: sid, index: idx); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) { + throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars'); + } + if (index < 0 || index > 254) { + throw ArgumentError.value(index, 'index', 'Expected 0..254'); + } + final out = Uint8List(6); + out[0] = _binaryMagic; + for (var i = 0; i < 4; i++) { + out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); + } + out[5] = index; + return out; + } +} + +int? _parseInt(String token, {required bool base36}) => + int.tryParse(token, radix: base36 ? 36 : 10); + +String _toBase36(int value) => value.toRadixString(36); + +String _encodeSessionId(String sessionIdHex) { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) { + throw ArgumentError.value( + sessionIdHex, + 'sessionIdHex', + 'Expected 8 hex chars', + ); + } + final value = int.parse(sessionIdHex, radix: 16); + return value.toRadixString(36); +} + +String? _decodeSessionId(String token) { + if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null; + final value = int.tryParse(token, radix: 36); + if (value == null || value < 0 || value > 0xFFFFFFFF) return null; + return value.toRadixString(16).padLeft(8, '0'); +} + +String _encodeMissingIndicesCompact(List indices) { + final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList() + ..sort(); + if (sorted.isEmpty) return ''; + final chunks = []; + var start = sorted.first; + var prev = sorted.first; + for (var i = 1; i < sorted.length; i++) { + final curr = sorted[i]; + if (curr == prev + 1) { + prev = curr; + continue; + } + chunks.add( + start == prev + ? _toBase36(start) + : '${_toBase36(start)}-${_toBase36(prev)}', + ); + start = curr; + prev = curr; + } + chunks.add( + start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', + ); + return chunks.join('.'); +} + +List _decodeMissingIndicesCompact(String encoded) { + final out = []; + for (final token in encoded.split('.')) { + if (token.isEmpty) continue; + if (!token.contains('-')) { + final value = int.tryParse(token, radix: 36); + if (value == null || value < 0 || value > 254) return const []; + out.add(value); + continue; + } + final parts = token.split('-'); + if (parts.length != 2) return const []; + final start = int.tryParse(parts[0], radix: 36); + final end = int.tryParse(parts[1], radix: 36); + if (start == null || end == null || start < 0 || end > 254 || start > end) { + return const []; + } + for (var i = start; i <= end; i++) { + out.add(i); + } + } + return out; +} + +/// Fragment the compressed image bytes into [ImagePacket] list. +/// +/// [sessionId] must be 8 lowercase hex chars. +/// [format] is the image format used. +/// Returns at most 255 packets; excess bytes are silently dropped. +List fragmentImage({ + required String sessionId, + required ImageFormat format, + required Uint8List bytes, + int maxDataBytes = ImagePacket.maxDataBytes, +}) { + final chunkSize = maxDataBytes.clamp(1, 255).toInt(); + final chunks = []; + for (var offset = 0; offset < bytes.length; offset += chunkSize) { + final end = (offset + chunkSize).clamp(0, bytes.length); + chunks.add(bytes.sublist(offset, end)); + if (chunks.length == 255) break; // protocol limit + } + final total = chunks.length; + return [ + for (var i = 0; i < total; i++) + ImagePacket( + sessionId: sessionId, + format: format, + index: i, + total: total, + data: chunks[i], + ), + ]; +} + +/// Reassemble image bytes from received [packets]. +/// +/// Returns null if any fragment is missing. +Uint8List? reassembleImage(List packets) { + if (packets.isEmpty) return null; + if (packets.any((p) => p == null)) return null; + final merged = []; + for (final p in packets) { + merged.addAll(p!.data); + } + return Uint8List.fromList(merged); +} diff --git a/lib/utils/key_comparison.dart b/lib/utils/key_comparison.dart new file mode 100644 index 0000000..8dd9905 --- /dev/null +++ b/lib/utils/key_comparison.dart @@ -0,0 +1,18 @@ +import 'dart:typed_data'; + +/// Extension on Uint8List to provide comparison functionality for public keys. +/// +/// This extension is used to compare MeshCore public keys (32 bytes) or their +/// prefixes (6 bytes) across the application. +extension Uint8ListComparison on Uint8List { + /// Compares this Uint8List with another for exact equality. + /// + /// Returns true if both lists have the same length and identical bytes. + bool matches(Uint8List other) { + if (length != other.length) return false; + for (int i = 0; i < length; i++) { + if (this[i] != other[i]) return false; + } + return true; + } +} diff --git a/lib/utils/link_quality.dart b/lib/utils/link_quality.dart new file mode 100644 index 0000000..efda7ec --- /dev/null +++ b/lib/utils/link_quality.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +int rssiScore(int rssiDbm) { + if (rssiDbm >= -60) return 5; + if (rssiDbm >= -70) return 4; + if (rssiDbm >= -80) return 3; + if (rssiDbm >= -90) return 2; + if (rssiDbm >= -100) return 1; + return 0; +} + +int snrScore(double snrDb) { + if (snrDb >= 10) return 5; + if (snrDb >= 5) return 4; + if (snrDb >= 0) return 3; + if (snrDb >= -5) return 2; + if (snrDb >= -10) return 1; + return 0; +} + +String linkQualityLabel(int? rssiDbm, double? snrDb) { + var totalScore = 0; + var metricCount = 0; + + if (rssiDbm != null) { + totalScore += rssiScore(rssiDbm); + metricCount += 1; + } + if (snrDb != null) { + totalScore += snrScore(snrDb); + metricCount += 1; + } + + if (metricCount == 0) return 'Weak'; + + final averageScore = totalScore / metricCount; + if (averageScore >= 4.5) return 'Excellent'; + if (averageScore >= 3.5) return 'Good'; + if (averageScore >= 2.5) return 'Fair'; + return 'Weak'; +} + +Color linkQualityColor(String quality) { + switch (quality) { + case 'Excellent': + return Colors.green; + case 'Good': + return Colors.lightGreen; + case 'Fair': + return Colors.orange; + default: + return Colors.redAccent; + } +} diff --git a/lib/utils/location_formats.dart b/lib/utils/location_formats.dart new file mode 100644 index 0000000..919c380 --- /dev/null +++ b/lib/utils/location_formats.dart @@ -0,0 +1,191 @@ +import 'dart:math' as math; + +enum CoordinateDisplayFormat { decimal, dms, utm } + +String formatCoordinates( + double latitude, + double longitude, + CoordinateDisplayFormat format, +) { + switch (format) { + case CoordinateDisplayFormat.decimal: + return '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; + case CoordinateDisplayFormat.dms: + return '${formatDmsCoordinate(latitude, true)} ${formatDmsCoordinate(longitude, false)}'; + case CoordinateDisplayFormat.utm: + return formatUtm(latitude, longitude); + } +} + +String formatDmsCoordinate(double degrees, bool isLatitude) { + final direction = isLatitude + ? (degrees >= 0 ? 'N' : 'S') + : (degrees >= 0 ? 'E' : 'W'); + + final absolute = degrees.abs(); + final deg = absolute.floor(); + final minDecimal = (absolute - deg) * 60; + final min = minDecimal.floor(); + final sec = (minDecimal - min) * 60; + + return '$deg°${min.toString().padLeft(2, '0')}\'${sec.toStringAsFixed(2).padLeft(5, '0')}"$direction'; +} + +String formatUtm(double latitude, double longitude) { + if (latitude < -80 || latitude > 84) { + return formatCoordinates( + latitude, + longitude, + CoordinateDisplayFormat.decimal, + ); + } + + final zone = _utmZone(latitude, longitude); + final band = _utmLatitudeBand(latitude); + final latRad = _degreesToRadians(latitude); + final lonRad = _degreesToRadians(longitude); + final centralMeridianRad = _degreesToRadians((zone - 1) * 6 - 180 + 3); + + const semiMajorAxis = 6378137.0; + const flattening = 1 / 298.257223563; + const scaleFactor = 0.9996; + final eccentricitySquared = flattening * (2 - flattening); + final secondEccentricitySquared = + eccentricitySquared / (1 - eccentricitySquared); + + final sinLat = math.sin(latRad); + final cosLat = math.cos(latRad); + final tanLat = math.tan(latRad); + final n = semiMajorAxis / + math.sqrt(1 - eccentricitySquared * sinLat * sinLat); + final t = tanLat * tanLat; + final c = secondEccentricitySquared * cosLat * cosLat; + final a = cosLat * (lonRad - centralMeridianRad); + + final meridianArc = semiMajorAxis * + ((1 - + eccentricitySquared / 4 - + 3 * eccentricitySquared * eccentricitySquared / 64 - + 5 * + eccentricitySquared * + eccentricitySquared * + eccentricitySquared / + 256) * + latRad - + (3 * eccentricitySquared / 8 + + 3 * eccentricitySquared * eccentricitySquared / 32 + + 45 * + eccentricitySquared * + eccentricitySquared * + eccentricitySquared / + 1024) * + math.sin(2 * latRad) + + (15 * eccentricitySquared * eccentricitySquared / 256 + + 45 * + eccentricitySquared * + eccentricitySquared * + eccentricitySquared / + 1024) * + math.sin(4 * latRad) - + (35 * + eccentricitySquared * + eccentricitySquared * + eccentricitySquared / + 3072) * + math.sin(6 * latRad)); + + final easting = scaleFactor * + n * + (a + + (1 - t + c) * a * a * a / 6 + + (5 - 18 * t + t * t + 72 * c - 58 * secondEccentricitySquared) * + a * + a * + a * + a * + a / + 120) + + 500000; + var northing = scaleFactor * + (meridianArc + + n * + tanLat * + (a * a / 2 + + (5 - t + 9 * c + 4 * c * c) * a * a * a * a / 24 + + (61 - + 58 * t + + t * t + + 600 * c - + 330 * secondEccentricitySquared) * + a * + a * + a * + a * + a * + a / + 720)); + if (latitude < 0) { + northing += 10000000; + } + + return '$zone$band ${easting.round()}E ${northing.round()}N'; +} + +int _utmZone(double latitude, double longitude) { + var zone = ((longitude + 180) / 6).floor() + 1; + if (longitude == 180) { + zone = 60; + } + if (latitude >= 56 && + latitude < 64 && + longitude >= 3 && + longitude < 12) { + zone = 32; + } + if (latitude >= 72 && latitude < 84) { + if (longitude >= 0 && longitude < 9) { + zone = 31; + } else if (longitude >= 9 && longitude < 21) { + zone = 33; + } else if (longitude >= 21 && longitude < 33) { + zone = 35; + } else if (longitude >= 33 && longitude < 42) { + zone = 37; + } + } + return zone.clamp(1, 60); +} + +String _utmLatitudeBand(double latitude) { + const bands = 'CDEFGHJKLMNPQRSTUVWX'; + final index = ((latitude + 80) / 8).floor().clamp(0, bands.length - 1); + return bands[index]; +} + +double _degreesToRadians(double degrees) => degrees * math.pi / 180; + +String formatPlusCode(double lat, double lon) { + const base = '23456789CFGHJMPQRVWX'; + + var normalizedLat = (lat + 90) / 180; + var normalizedLon = (lon + 180) / 360; + + final buffer = StringBuffer(); + for (var i = 0; i < 8; i++) { + if (i == 4) { + buffer.write('+'); + } + + final latDigit = (normalizedLat * 20).floor() % 20; + final lonDigit = (normalizedLon * 20).floor() % 20; + + buffer + ..write(base[latDigit]) + ..write(base[lonDigit]); + + normalizedLat = (normalizedLat * 20) % 1; + normalizedLon = (normalizedLon * 20) % 1; + } + + return buffer.toString(); +} diff --git a/lib/utils/log_rx_route_decoder.dart b/lib/utils/log_rx_route_decoder.dart new file mode 100644 index 0000000..fa17485 --- /dev/null +++ b/lib/utils/log_rx_route_decoder.dart @@ -0,0 +1,260 @@ +import 'dart:typed_data'; + +import '../models/contact.dart'; + +class DecodedLogRxRoute { + final int payloadType; + final int pathDescriptor; + final List pathBytes; + final int hashSize; + + const DecodedLogRxRoute({ + required this.payloadType, + required this.pathDescriptor, + required this.pathBytes, + required this.hashSize, + }); + + List get hopHashes => + LogRxRouteDecoder.splitHopHashes(pathBytes, hashSize: hashSize); + + int get hopCount => hopHashes.length; + + String? get originalSenderHashHex => + hopHashes.isEmpty ? null : hopHashes.first; +} + +class ResolvedNodeHash { + final String hashHex; + final String label; + final bool isOwnNode; + final bool isUniqueMatch; + final int matchCount; + final double? latitude; + final double? longitude; + + const ResolvedNodeHash({ + required this.hashHex, + required this.label, + required this.isOwnNode, + required this.isUniqueMatch, + required this.matchCount, + this.latitude, + this.longitude, + }); + + String get hexLabel => '0x${hashHex.toUpperCase()}'; +} + +class LogRxRouteDecoder { + const LogRxRouteDecoder._(); + + static DecodedLogRxRoute? decode( + Uint8List rawData, { + int? preferredHashSize, + }) { + if (rawData.length < 5 || rawData[0] != 0x88) return null; + + final rawPacketData = rawData.sublist(3); + if (rawPacketData.length < 2) return null; + + final header = rawPacketData[0]; + final routeType = header & 0x03; + final payloadType = (header >> 2) & 0x0F; + + var index = 1; + if (routeType == 0x00 || routeType == 0x03) { + if (rawPacketData.length < index + 5) return null; + index += 4; + } + + if (rawPacketData.length <= index) return null; + final pathDescriptor = rawPacketData[index++]; + final pathMode = (pathDescriptor & 0xFF) >> 6; + final pathByteLen = pathMode == 0 + ? pathDescriptor + : descriptorByteLength(pathDescriptor); + if (pathByteLen == null || rawPacketData.length < index + pathByteLen) { + return null; + } + final pathBytes = rawPacketData.sublist(index, index + pathByteLen); + final hashSize = pathMode == 0 + ? 1 + : (descriptorHashSize(pathDescriptor) ?? + inferHashSize(pathBytes, preferredHashSize: preferredHashSize)); + + return DecodedLogRxRoute( + payloadType: payloadType, + pathDescriptor: pathDescriptor, + pathBytes: pathBytes, + hashSize: hashSize, + ); + } + + static int? descriptorHashSize(int pathDescriptor) { + final normalized = pathDescriptor & 0xFF; + final mode = normalized >> 6; + if (mode == 3) return null; + return mode + 1; + } + + static int? descriptorHopCount(int pathDescriptor) { + final hashSize = descriptorHashSize(pathDescriptor); + if (hashSize == null) return null; + return (pathDescriptor & 0xFF) & 0x3F; + } + + static int? descriptorByteLength(int pathDescriptor) { + final hashSize = descriptorHashSize(pathDescriptor); + final hopCount = descriptorHopCount(pathDescriptor); + if (hashSize == null || hopCount == null) return null; + final byteLen = hopCount * hashSize; + return byteLen <= 64 ? byteLen : null; + } + + static int inferHashSize(List pathBytes, {int? preferredHashSize}) { + if (pathBytes.isEmpty) return 1; + + final normalizedPreferred = + preferredHashSize != null && + preferredHashSize >= 1 && + preferredHashSize <= 3 + ? preferredHashSize + : null; + if (normalizedPreferred != null && + pathBytes.length % normalizedPreferred == 0) { + return normalizedPreferred; + } + + for (final candidate in const [3, 2, 1]) { + if (pathBytes.length % candidate == 0) { + return candidate; + } + } + + return 1; + } + + static List splitHopHashes( + List pathBytes, { + required int hashSize, + }) { + if (pathBytes.isEmpty) return const []; + if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) { + return pathBytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .toList(); + } + + final hops = []; + for (var index = 0; index < pathBytes.length; index += hashSize) { + hops.add( + pathBytes + .sublist(index, index + hashSize) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(), + ); + } + return hops; + } + + static List reverseHopBytes( + List pathBytes, { + required int hashSize, + }) { + if (pathBytes.isEmpty) return const []; + if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) { + return List.from(pathBytes.reversed); + } + + final reversed = []; + for ( + var index = pathBytes.length - hashSize; + index >= 0; + index -= hashSize + ) { + reversed.addAll(pathBytes.sublist(index, index + hashSize)); + } + return reversed; + } + + static ResolvedNodeHash resolveHash( + String hashHex, { + required Iterable contacts, + Uint8List? ownPublicKey, + String? ownName, + double? ownLatitude, + double? ownLongitude, + }) { + final normalizedHashHex = hashHex.toLowerCase(); + final ownKeyHex = _bytesToHex(ownPublicKey); + if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) { + final ownLabel = (ownName != null && ownName.trim().isNotEmpty) + ? '$ownName (you)' + : 'You'; + return ResolvedNodeHash( + hashHex: normalizedHashHex, + label: ownLabel, + isOwnNode: true, + isUniqueMatch: true, + matchCount: 1, + latitude: ownLatitude, + longitude: ownLongitude, + ); + } + + final matches = contacts.where((contact) { + return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex); + }).toList(); + + if (matches.isEmpty) { + return ResolvedNodeHash( + hashHex: normalizedHashHex, + label: 'Unknown', + isOwnNode: false, + isUniqueMatch: false, + matchCount: 0, + ); + } + + if (matches.length == 1) { + final location = matches.first.displayLocation; + return ResolvedNodeHash( + hashHex: normalizedHashHex, + label: matches.first.displayName, + isOwnNode: false, + isUniqueMatch: true, + matchCount: 1, + latitude: location?.latitude, + longitude: location?.longitude, + ); + } + + final candidateNames = matches + .map((contact) => contact.displayName) + .where((name) => name.trim().isNotEmpty) + .take(2) + .join(', '); + final extraCount = matches.length - 2; + final label = candidateNames.isEmpty + ? '${matches.length} contacts' + : extraCount > 0 + ? '$candidateNames +$extraCount' + : candidateNames; + return ResolvedNodeHash( + hashHex: normalizedHashHex, + label: label, + isOwnNode: false, + isUniqueMatch: false, + matchCount: matches.length, + ); + } + + static String? _bytesToHex(Uint8List? bytes) { + if (bytes == null || bytes.isEmpty) return null; + return bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + } +} diff --git a/lib/utils/media_swarm_protocol.dart b/lib/utils/media_swarm_protocol.dart new file mode 100644 index 0000000..70191c5 --- /dev/null +++ b/lib/utils/media_swarm_protocol.dart @@ -0,0 +1,168 @@ +import 'dart:typed_data'; + +const int _swarmMagic = 0x6d; // 'm' +const int _swarmKindRequest = 0x01; +const int _swarmKindAvailability = 0x02; + +class MediaSwarmRequest { + final String mediaType; + final String sessionId; + final String requesterKey6; + final List missingIndices; + + const MediaSwarmRequest({ + required this.mediaType, + required this.sessionId, + required this.requesterKey6, + this.missingIndices = const [], + }); + + bool get requestsAll => missingIndices.isEmpty; + + Uint8List encodeBinary() { + final normalizedMissing = missingIndices.toSet().toList()..sort(); + final out = Uint8List(14 + normalizedMissing.length); + out[0] = _swarmMagic; + out[1] = _swarmKindRequest; + out[2] = _encodeMediaType(mediaType); + _writeSessionId(out, 3, sessionId); + _writeKey6(out, 7, requesterKey6); + out[13] = normalizedMissing.length; + for (var i = 0; i < normalizedMissing.length; i++) { + out[14 + i] = normalizedMissing[i]; + } + return out; + } + + static MediaSwarmRequest? tryParseBinary(Uint8List payload) { + if (payload.length < 14 || + payload[0] != _swarmMagic || + payload[1] != _swarmKindRequest) { + return null; + } + + final mediaType = _decodeMediaType(payload[2]); + if (mediaType == null) return null; + + final missingCount = payload[13]; + if (payload.length != 14 + missingCount) { + return null; + } + + return MediaSwarmRequest( + mediaType: mediaType, + sessionId: _readSessionId(payload, 3), + requesterKey6: _readKey6(payload, 7), + missingIndices: payload.sublist(14), + ); + } +} + +class MediaSwarmAvailability { + final String mediaType; + final String sessionId; + final String requesterKey6; + final String responderKey6; + final List availableIndices; + + const MediaSwarmAvailability({ + required this.mediaType, + required this.sessionId, + required this.requesterKey6, + required this.responderKey6, + required this.availableIndices, + }); + + bool get servesAll => availableIndices.isEmpty; + + Uint8List encodeBinary() { + final normalizedAvailable = availableIndices.toSet().toList()..sort(); + final out = Uint8List(20 + normalizedAvailable.length); + out[0] = _swarmMagic; + out[1] = _swarmKindAvailability; + out[2] = _encodeMediaType(mediaType); + _writeSessionId(out, 3, sessionId); + _writeKey6(out, 7, requesterKey6); + _writeKey6(out, 13, responderKey6); + out[19] = normalizedAvailable.length; + for (var i = 0; i < normalizedAvailable.length; i++) { + out[20 + i] = normalizedAvailable[i]; + } + return out; + } + + static MediaSwarmAvailability? tryParseBinary(Uint8List payload) { + if (payload.length < 20 || + payload[0] != _swarmMagic || + payload[1] != _swarmKindAvailability) { + return null; + } + + final mediaType = _decodeMediaType(payload[2]); + if (mediaType == null) return null; + + final availableCount = payload[19]; + if (payload.length != 20 + availableCount) { + return null; + } + + return MediaSwarmAvailability( + mediaType: mediaType, + sessionId: _readSessionId(payload, 3), + requesterKey6: _readKey6(payload, 7), + responderKey6: _readKey6(payload, 13), + availableIndices: payload.sublist(20), + ); + } +} + +int _encodeMediaType(String mediaType) { + return switch (mediaType) { + 'voice' => 0x01, + 'image' => 0x02, + _ => throw ArgumentError.value(mediaType, 'mediaType'), + }; +} + +String? _decodeMediaType(int raw) { + return switch (raw) { + 0x01 => 'voice', + 0x02 => 'image', + _ => null, + }; +} + +void _writeSessionId(Uint8List out, int offset, String sessionId) { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) { + throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars'); + } + for (var i = 0; i < 4; i++) { + out[offset + i] = int.parse( + sessionId.substring(i * 2, i * 2 + 2), + radix: 16, + ); + } +} + +String _readSessionId(Uint8List payload, int offset) { + return payload + .sublist(offset, offset + 4) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); +} + +void _writeKey6(Uint8List out, int offset, String key6) { + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(key6)) { + throw ArgumentError.value(key6, 'key6', 'Expected 12 hex chars'); + } + for (var i = 0; i < 6; i++) { + out[offset + i] = int.parse(key6.substring(i * 2, i * 2 + 2), radix: 16); + } +} + +String _readKey6(Uint8List payload, int offset) { + return payload + .sublist(offset, offset + 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); +} diff --git a/lib/utils/message_airtime_estimator.dart b/lib/utils/message_airtime_estimator.dart new file mode 100644 index 0000000..01c9f78 --- /dev/null +++ b/lib/utils/message_airtime_estimator.dart @@ -0,0 +1,149 @@ +import '../models/message.dart'; +import 'image_message_parser.dart'; +import 'voice_message_parser.dart'; + +const int _defaultLoRaSf = 10; +const int _defaultLoRaCr = 5; +const int _defaultLoRaBwHz = 250000; +const int _defaultLoRaPreambleSymbols = 8; +const int _defaultLoRaCrcEnabled = 1; +const int _defaultLoRaExplicitHeader = 1; +const double _defaultAirtimeBudgetFactor = 1.0; +const int _meshPacketHeaderBytes = 2; +const int _textFrameBaseBytes = 10; + +Duration estimateMessageTransmitDuration( + Message message, { + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final imageEnvelope = ImageEnvelope.tryParse(message.text); + if (imageEnvelope != null) { + return estimateImageTransmitDuration( + fragmentCount: imageEnvelope.total, + sizeBytes: imageEnvelope.sizeBytes, + pathLen: message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final voiceEnvelope = VoiceEnvelope.tryParseText(message.text); + if (voiceEnvelope != null) { + return estimateVoiceTransmitDuration( + mode: voiceEnvelope.mode, + packetCount: voiceEnvelope.total, + durationMs: voiceEnvelope.durationMs, + pathLen: message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final voicePacket = VoicePacket.tryParseText(message.text); + if (voicePacket != null) { + return estimateVoiceTransmitDuration( + mode: voicePacket.mode, + packetCount: voicePacket.total, + durationMs: voicePacket.durationMs * voicePacket.total, + pathLen: message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final normalizedPathLen = _normalizedPathLen(message.pathLen); + final payloadBytes = _textFrameBaseBytes + message.text.length; + final hops = normalizedPathLen + 1; + final airtimeMs = _estimateLoRaAirtimeMs( + _meshPacketHeaderBytes + normalizedPathLen + payloadBytes, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + + return Duration( + milliseconds: (airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops) + .round(), + ); +} + +int _normalizedPathLen(int pathLen) { + if (pathLen < 0 || pathLen >= 255) return 0; + return pathLen.clamp(0, 64).toInt(); +} + +double _estimateLoRaAirtimeMs( + int payloadLenBytes, { + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final sf = _normalizeSf(radioSf); + final bw = _resolveBandwidthHz(radioBw).toDouble(); + final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4); + final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1; + final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0; + + final symbolMs = ((1 << sf) / bw) * 1000.0; + final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs; + + final num = + (8 * payloadLenBytes) - + (4 * sf) + + 28 + + (16 * _defaultLoRaCrcEnabled) - + (20 * ih); + final den = 4 * (sf - (2 * de)); + final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil(); + final payloadSymbols = + 8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4); + final payloadMs = payloadSymbols * symbolMs; + + return preambleMs + payloadMs; +} + +int _normalizeSf(int? value) { + if (value == null) return _defaultLoRaSf; + if (value >= 5 && value <= 12) return value; + return _defaultLoRaSf; +} + +int _normalizeCr(int? value) { + if (value == null) return _defaultLoRaCr; + if (value >= 5 && value <= 8) return value; + return _defaultLoRaCr; +} + +int _resolveBandwidthHz(int? rawBw) { + if (rawBw == null) return _defaultLoRaBwHz; + if (rawBw > 1000) return rawBw; + switch (rawBw) { + case 0: + return 7800; + case 1: + return 10400; + case 2: + return 15600; + case 3: + return 20800; + case 4: + return 31250; + case 5: + return 41700; + case 6: + return 62500; + case 7: + return 125000; + case 8: + return 250000; + case 9: + return 500000; + default: + return _defaultLoRaBwHz; + } +} diff --git a/lib/utils/message_extensions.dart b/lib/utils/message_extensions.dart new file mode 100644 index 0000000..a516a3f --- /dev/null +++ b/lib/utils/message_extensions.dart @@ -0,0 +1,181 @@ +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; +import '../models/message.dart'; +import '../models/message_route_metadata.dart'; +import '../l10n/app_localizations.dart'; +import '../providers/messages_provider.dart'; + +/// Extension for Message to provide localized delivery status +extension MessageLocalization on Message { + /// Get localized delivery status text + String getLocalizedDeliveryStatus(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final messagesProvider = context.read(); + final routeMetadata = messagesProvider.getMessageRouteMetadata(id); + + // For channel messages, show echo count instead of delivery status + if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) { + final latestMeta = _formatChannelStatusMeta(context, routeMetadata); + if (echoCount == 0) { + if (messagesProvider.hasChannelSendWarning(id)) { + return 'Broadcast may have failed'; + } + return l10n.broadcast; // "Broadcast (no echoes yet)" + } else if (echoCount == 1) { + return latestMeta == null ? '1 node' : '1 node • $latestMeta'; + } else { + return latestMeta == null + ? '$echoCount nodes' + : '$echoCount nodes • $latestMeta'; + } + } + + switch (deliveryStatus) { + case MessageDeliveryStatus.sending: + if (isContactMessage) { + if (retryAttempt > 0) { + final routeSuffix = routeMetadata != null + ? ' • ${routeMetadata.modeLabel}' + : ''; + return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix'; + } + return routeMetadata == null + ? l10n.pending + : '${l10n.pending} • ${routeMetadata.modeLabel}'; + } + return l10n.sending; + case MessageDeliveryStatus.sent: + return routeMetadata == null + ? l10n.sent + : '${l10n.sent} • ${routeMetadata.modeLabel}'; + case MessageDeliveryStatus.delivered: + if (retryAttempt > 0 && roundTripTimeMs != null) { + final routeSuffix = routeMetadata != null + ? ' • ${routeMetadata.modeLabel}' + : ''; + return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix'; + } + if (retryAttempt > 0) { + final routeSuffix = routeMetadata != null + ? ' • ${routeMetadata.modeLabel}' + : ''; + return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix'; + } + if (roundTripTimeMs != null) { + return routeMetadata == null + ? l10n.deliveredWithTime(roundTripTimeMs!) + : '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${routeMetadata.modeLabel}'; + } + return routeMetadata == null + ? l10n.delivered + : '${l10n.delivered} • ${routeMetadata.modeLabel}'; + case MessageDeliveryStatus.failed: + if (retryAttempt > 0) { + final routeSuffix = routeMetadata != null + ? ' • ${routeMetadata.modeLabel}' + : ''; + return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix'; + } + return routeMetadata == null + ? l10n.failed + : '${l10n.failed} • ${routeMetadata.modeLabel}'; + case MessageDeliveryStatus.received: + return ''; + } + } + + /// Get localized time ago string + String getLocalizedTimeAgo(BuildContext context) { + final diff = DateTime.now().difference(sentAt); + final l10n = AppLocalizations.of(context)!; + + if (diff.inMinutes < 1) return l10n.justNow; + if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); + if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours); + return l10n.daysAgo(diff.inDays); + } + + String? _formatEchoMeta(BuildContext context) { + if (lastEchoSnrRaw == null && + lastEchoRssiDbm == null && + lastEchoAt == null) { + return null; + } + + final parts = []; + if (lastEchoRssiDbm != null) { + parts.add('R ${_barsForRssi(lastEchoRssiDbm!)} $lastEchoRssiDbm dBm'); + } + if (lastEchoSnrRaw != null) { + final snrDb = lastEchoSnrRaw!.toSigned(8) / 4.0; + parts.add('S ${_barsForSnr(snrDb)} ${snrDb.toStringAsFixed(1)} dB'); + } + if (lastEchoAt != null) { + final diff = DateTime.now().difference(lastEchoAt!); + final l10n = AppLocalizations.of(context)!; + if (diff.inMinutes < 1) { + parts.add(l10n.justNow); + } else if (diff.inMinutes < 60) { + parts.add(l10n.minutesAgo(diff.inMinutes)); + } else if (diff.inHours < 24) { + parts.add(l10n.hoursAgo(diff.inHours)); + } else { + parts.add(l10n.daysAgo(diff.inDays)); + } + } + + if (parts.isEmpty) return null; + return parts.join(' • '); + } + + String? _formatChannelStatusMeta( + BuildContext context, + MessageRouteMetadata? routeMetadata, + ) { + final parts = []; + final hopLabel = _formatChannelHopMeta(routeMetadata); + if (hopLabel != null) { + parts.add(hopLabel); + } + + final echoMeta = _formatEchoMeta(context); + if (echoMeta != null) { + parts.add(echoMeta); + } + + if (parts.isEmpty) { + return null; + } + + return parts.join(' • '); + } + + String? _formatChannelHopMeta(MessageRouteMetadata? routeMetadata) { + if (routeMetadata?.mode.name == 'flood') { + return routeMetadata!.modeLabel; + } + + final effectivePathLen = routeMetadata?.hopCount ?? pathLen; + if (effectivePathLen <= 0 || effectivePathLen >= 255) { + return null; + } + + return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}'; + } + + String _barsForRssi(int rssiDbm) { + // Approximate useful RSSI range: -120..-70 dBm + final score = ((rssiDbm + 120) / 10).round().clamp(0, 5); + return _asciiBars(score, 5); + } + + String _barsForSnr(double snrDb) { + // Approximate useful SNR range: -5..+20 dB + final score = ((snrDb + 5.0) / 5.0).round().clamp(0, 5); + return _asciiBars(score, 5); + } + + String _asciiBars(int filled, int total) { + return '[${'#' * filled}${'-' * (total - filled)}]'; + } +} diff --git a/lib/utils/raw_route_probe.dart b/lib/utils/raw_route_probe.dart new file mode 100644 index 0000000..4782a06 --- /dev/null +++ b/lib/utils/raw_route_probe.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +class RawRouteProbeRequest { + static const int _binaryMagic = 0x70; // 'p' + + final int nonce; + final String requesterKey6; + + const RawRouteProbeRequest({ + required this.nonce, + required this.requesterKey6, + }); + + static RawRouteProbeRequest? tryParseBinary(Uint8List payload) { + if (payload.length != 11 || payload[0] != _binaryMagic) return null; + try { + final nonce = + (payload[1] << 24) | + (payload[2] << 16) | + (payload[3] << 8) | + payload[4]; + final requesterKey6 = payload + .sublist(5, 11) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + return RawRouteProbeRequest(nonce: nonce, requesterKey6: requesterKey6); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { + throw ArgumentError.value( + requesterKey6, + 'requesterKey6', + 'Expected 12 hex chars', + ); + } + final out = Uint8List(11); + out[0] = _binaryMagic; + out[1] = (nonce >> 24) & 0xFF; + out[2] = (nonce >> 16) & 0xFF; + out[3] = (nonce >> 8) & 0xFF; + out[4] = nonce & 0xFF; + for (var i = 0; i < 6; i++) { + out[5 + i] = int.parse( + requesterKey6.substring(i * 2, i * 2 + 2), + radix: 16, + ); + } + return out; + } +} + +class RawRouteProbeAck { + static const int _binaryMagic = 0x71; // 'q' + + final int nonce; + + const RawRouteProbeAck({required this.nonce}); + + static RawRouteProbeAck? tryParseBinary(Uint8List payload) { + if (payload.length != 5 || payload[0] != _binaryMagic) return null; + try { + final nonce = + (payload[1] << 24) | + (payload[2] << 16) | + (payload[3] << 8) | + payload[4]; + return RawRouteProbeAck(nonce: nonce); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + final out = Uint8List(5); + out[0] = _binaryMagic; + out[1] = (nonce >> 24) & 0xFF; + out[2] = (nonce >> 16) & 0xFF; + out[3] = (nonce >> 8) & 0xFF; + out[4] = nonce & 0xFF; + return out; + } +} diff --git a/lib/utils/rssi_location_estimator.dart b/lib/utils/rssi_location_estimator.dart new file mode 100644 index 0000000..d2be4de --- /dev/null +++ b/lib/utils/rssi_location_estimator.dart @@ -0,0 +1,160 @@ +import 'dart:math' as math; +import 'package:latlong2/latlong.dart'; + +/// An RSSI observation from a single repeater. +class RssiObservation { + final LatLng repeaterLocation; + final int rssiDbm; + final DateTime observedAt; + + const RssiObservation({ + required this.repeaterLocation, + required this.rssiDbm, + required this.observedAt, + }); + + double? get estimatedDistanceMeters => + RssiLocationEstimator.estimateDistanceMeters(rssiDbm); +} + +/// Estimate distance from RSSI using the log-distance path loss model. +/// +/// For LoRa at ~900 MHz: +/// - Path loss exponent (n) ≈ 2.7-3.5 for outdoor environments +/// - Reference distance: 1m, reference RSSI: -30 dBm (typical LoRa at 1m) +/// +/// Formula: distance = 10 ^ ((txPower - rssi) / (10 * n)) +class RssiLocationEstimator { + /// Estimate distance in meters from RSSI value. + static double? estimateDistanceMeters(int rssiDbm) { + if (rssiDbm > -20 || rssiDbm < -140) return null; + + const double referenceRssi = -30.0; + const double pathLossExponent = 3.0; + + final distance = math.pow( + 10.0, + (referenceRssi - rssiDbm) / (10.0 * pathLossExponent), + ).toDouble(); + + return distance.clamp(10.0, 50000.0); + } + + /// Offset a point by distance and bearing (Haversine inverse). + static LatLng offsetPoint( + LatLng origin, + double distanceMeters, + double bearingDegrees, + ) { + const double earthRadius = 6371000.0; + final lat1 = origin.latitude * math.pi / 180.0; + final lon1 = origin.longitude * math.pi / 180.0; + final bearing = bearingDegrees * math.pi / 180.0; + final angDist = distanceMeters / earthRadius; + + final lat2 = math.asin( + math.sin(lat1) * math.cos(angDist) + + math.cos(lat1) * math.sin(angDist) * math.cos(bearing), + ); + final lon2 = lon1 + + math.atan2( + math.sin(bearing) * math.sin(angDist) * math.cos(lat1), + math.cos(angDist) - math.sin(lat1) * math.sin(lat2), + ); + + return LatLng(lat2 * 180.0 / math.pi, lon2 * 180.0 / math.pi); + } + + /// Single-repeater estimate: offset from repeater by RSSI distance. + static LatLng? estimateFromRepeater({ + required LatLng repeaterLocation, + required int rssiDbm, + required List contactPublicKey, + }) { + final distance = estimateDistanceMeters(rssiDbm); + if (distance == null) return null; + + final keyHash = contactPublicKey.fold(0, (a, b) => a ^ b); + final bearing = (keyHash % 360).toDouble(); + + return offsetPoint(repeaterLocation, distance, bearing); + } + + /// Trilateration from multiple RSSI observations. + /// + /// With 1 observation: offset from repeater (bearing from key hash). + /// With 2 observations: weighted midpoint on the line between circles. + /// With 3+ observations: weighted centroid of circle intersection region. + /// + /// Each observation is weighted by 1/distance² (closer = more accurate). + static LatLng? trilaterate({ + required List observations, + required List contactPublicKey, + Duration maxAge = const Duration(minutes: 30), + }) { + final now = DateTime.now(); + final recent = observations + .where((o) => + now.difference(o.observedAt) <= maxAge && + o.estimatedDistanceMeters != null) + .toList(); + + if (recent.isEmpty) return null; + + if (recent.length == 1) { + return estimateFromRepeater( + repeaterLocation: recent.first.repeaterLocation, + rssiDbm: recent.first.rssiDbm, + contactPublicKey: contactPublicKey, + ); + } + + // Weighted centroid: each repeater contributes a candidate point + // on the circle towards the centroid of all repeaters. + // Weight = 1/distance² (inverse square — closer observations dominate). + + // Step 1: compute raw centroid of all repeater locations + double centroidLat = 0, centroidLon = 0; + for (final obs in recent) { + centroidLat += obs.repeaterLocation.latitude; + centroidLon += obs.repeaterLocation.longitude; + } + centroidLat /= recent.length; + centroidLon /= recent.length; + final centroid = LatLng(centroidLat, centroidLon); + + // Step 2: for each observation, compute a candidate point on the + // circle (at RSSI distance) in the direction of the centroid. + double weightedLat = 0, weightedLon = 0, totalWeight = 0; + + for (final obs in recent) { + final dist = obs.estimatedDistanceMeters!; + final weight = 1.0 / (dist * dist); + + // Bearing from this repeater towards the centroid + final bearing = _bearingDegrees(obs.repeaterLocation, centroid); + final candidate = offsetPoint(obs.repeaterLocation, dist, bearing); + + weightedLat += candidate.latitude * weight; + weightedLon += candidate.longitude * weight; + totalWeight += weight; + } + + if (totalWeight <= 0) return null; + + return LatLng(weightedLat / totalWeight, weightedLon / totalWeight); + } + + /// Bearing in degrees from point A to point B. + static double _bearingDegrees(LatLng a, LatLng b) { + final lat1 = a.latitude * math.pi / 180.0; + final lat2 = b.latitude * math.pi / 180.0; + final dLon = (b.longitude - a.longitude) * math.pi / 180.0; + + final y = math.sin(dLon) * math.cos(lat2); + final x = math.cos(lat1) * math.sin(lat2) - + math.sin(lat1) * math.cos(lat2) * math.cos(dLon); + + return (math.atan2(y, x) * 180.0 / math.pi + 360.0) % 360.0; + } +} diff --git a/lib/utils/sample_data_generator.dart b/lib/utils/sample_data_generator.dart new file mode 100644 index 0000000..e8defb8 --- /dev/null +++ b/lib/utils/sample_data_generator.dart @@ -0,0 +1,525 @@ +import 'dart:typed_data'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import '../models/contact.dart'; +import '../models/message.dart'; +import '../models/message_contact_location.dart'; +import '../l10n/app_localizations.dart'; + +/// Generates sample data for testing/demo purposes +class SampleDataGenerator { + static final Random _random = Random(); + + static MessageContactLocation _sampleSnapshot({ + required LatLng location, + required DateTime receivedAt, + required String source, + }) { + return MessageContactLocation( + location: location, + source: source, + capturedAt: receivedAt, + sourceTimestamp: receivedAt.subtract(const Duration(minutes: 2)), + ); + } + + static LatLng _randomNearbyLocation(LatLng center, double spread) { + final latOffset = (_random.nextDouble() - 0.5) * spread; + final lonOffset = (_random.nextDouble() - 0.5) * spread; + return LatLng(center.latitude + latOffset, center.longitude + lonOffset); + } + + static SampleMessageBatch generateAllMessages({ + required LatLng centerLocation, + required AppLocalizations l10n, + int foundPersonCount = 2, + int fireCount = 1, + int stagingCount = 1, + int objectCount = 1, + int generalChannelMessages = 8, + int emergencyChannelMessages = 5, + }) { + final sarBatch = generateSarMarkerMessages( + centerLocation: centerLocation, + l10n: l10n, + foundPersonCount: foundPersonCount, + fireCount: fireCount, + stagingCount: stagingCount, + objectCount: objectCount, + ); + final channelBatch = generateChannelMessages( + centerLocation: centerLocation, + l10n: l10n, + generalChannelMessages: generalChannelMessages, + emergencyChannelMessages: emergencyChannelMessages, + ); + + return SampleMessageBatch( + messages: [...sarBatch.messages, ...channelBatch.messages], + contactLocations: { + ...sarBatch.contactLocations, + ...channelBatch.contactLocations, + }, + ); + } + + /// Generate sample contacts around a center location + static List generateContacts({ + required LatLng centerLocation, + required AppLocalizations l10n, + int teamMemberCount = 5, + int channelCount = 2, + }) { + final contacts = []; + final now = DateTime.now(); + + final teamNames = [ + '👮${l10n.samplePoliceLead}', + '🚁${l10n.sampleDroneOperator}', + '🧑🏻‍🚒${l10n.sampleFirefighterAlpha}', + '🧑‍⚕️${l10n.sampleMedicCharlie}', + '📡${l10n.sampleCommandDelta}', + '🚒${l10n.sampleFireEngine}', + '👨‍✈️${l10n.sampleAirSupport}', + '🧑‍💼${l10n.sampleBaseCoordinator}', + ]; + + final channelNames = [ + l10n.general, + l10n.channelEmergency, + l10n.channelCoordination, + l10n.channelUpdates, + ]; + + // Generate team members (chat contacts) + for (int i = 0; i < teamMemberCount && i < teamNames.length; i++) { + // Generate location within ~1km radius + final latOffset = (_random.nextDouble() - 0.5) * 0.02; // ~1km + final lonOffset = (_random.nextDouble() - 0.5) * 0.02; + final lat = centerLocation.latitude + latOffset; + final lon = centerLocation.longitude + lonOffset; + + // Generate random public key + final publicKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + // Random battery 20-100% + final battery = 20 + _random.nextInt(81); + + // Random temperature 15-35°C + final temp = 15.0 + _random.nextDouble() * 20.0; + + final telemetry = ContactTelemetry( + gpsLocation: LatLng(lat, lon), + batteryPercentage: battery.toDouble(), + batteryMilliVolts: 3000.0 + (battery / 100.0) * 1200.0, + temperature: temp, + timestamp: now.subtract(Duration(minutes: _random.nextInt(10))), + ); + + final contact = Contact( + publicKey: publicKey, + type: ContactType.chat, + flags: 0, + outPathLen: 1, + outPath: Uint8List(32), + advName: teamNames[i], + lastAdvert: now.millisecondsSinceEpoch ~/ 1000, + advLat: (lat * 1e6).toInt(), + advLon: (lon * 1e6).toInt(), + lastMod: now.millisecondsSinceEpoch ~/ 1000, + telemetry: telemetry, + ); + + contacts.add(contact); + } + + // Generate channels/rooms + for (int i = 0; i < channelCount && i < channelNames.length; i++) { + // Generate random public key + final publicKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + // Channel index stored in outPath[0] + final outPath = Uint8List(32); + outPath[0] = i; // Channel index + + final channel = Contact( + publicKey: publicKey, + type: ContactType.room, + flags: 0, + outPathLen: 1, + outPath: outPath, + advName: channelNames[i], + lastAdvert: now.millisecondsSinceEpoch ~/ 1000, + advLat: 0, // Channels don't have location + advLon: 0, + lastMod: now.millisecondsSinceEpoch ~/ 1000, + ); + + contacts.add(channel); + } + + return contacts; + } + + /// Generate sample SAR markers around a center location + static SampleMessageBatch generateSarMarkerMessages({ + required LatLng centerLocation, + required AppLocalizations l10n, + int foundPersonCount = 2, + int fireCount = 1, + int stagingCount = 1, + int objectCount = 1, + }) { + final messages = []; + final contactLocations = {}; + final now = DateTime.now(); + int messageId = 1; + + // Generate found person markers + for (int i = 0; i < foundPersonCount; i++) { + final latOffset = (_random.nextDouble() - 0.5) * 0.015; + final lonOffset = (_random.nextDouble() - 0.5) * 0.015; + final lat = centerLocation.latitude + latOffset; + final lon = centerLocation.longitude + lonOffset; + + final senderKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + final timestamp = now.subtract(Duration(minutes: 10 + i * 5)); + final message = Message( + id: 'sample_fp_$messageId', + messageType: MessageType.contact, + senderPublicKeyPrefix: senderKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, + text: 'S:🧑:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}', + receivedAt: timestamp, + isSarMarker: true, + sarGpsCoordinates: LatLng(lat, lon), + sarCustomEmoji: '🧑', + senderName: l10n.sampleTeamMember, + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.006), + receivedAt: timestamp, + source: 'telemetry', + ); + messageId++; + } + + // Generate fire markers + for (int i = 0; i < fireCount; i++) { + final latOffset = (_random.nextDouble() - 0.5) * 0.015; + final lonOffset = (_random.nextDouble() - 0.5) * 0.015; + final lat = centerLocation.latitude + latOffset; + final lon = centerLocation.longitude + lonOffset; + + final senderKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + final timestamp = now.subtract(Duration(minutes: 20 + i * 5)); + final message = Message( + id: 'sample_fire_$messageId', + messageType: MessageType.contact, + senderPublicKeyPrefix: senderKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, + text: 'S:🔥:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}', + receivedAt: timestamp, + isSarMarker: true, + sarGpsCoordinates: LatLng(lat, lon), + sarCustomEmoji: '🔥', + senderName: l10n.sampleScout, + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.008), + receivedAt: timestamp, + source: 'advert', + ); + messageId++; + } + + // Generate staging area markers + for (int i = 0; i < stagingCount; i++) { + final latOffset = (_random.nextDouble() - 0.5) * 0.015; + final lonOffset = (_random.nextDouble() - 0.5) * 0.015; + final lat = centerLocation.latitude + latOffset; + final lon = centerLocation.longitude + lonOffset; + + final senderKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + final timestamp = now.subtract(Duration(minutes: 30 + i * 5)); + final message = Message( + id: 'sample_staging_$messageId', + messageType: MessageType.contact, + senderPublicKeyPrefix: senderKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, + text: 'S:🏕️:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}', + receivedAt: timestamp, + isSarMarker: true, + sarGpsCoordinates: LatLng(lat, lon), + sarCustomEmoji: '🏕️', + senderName: l10n.sampleBase, + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.01), + receivedAt: timestamp, + source: 'advert', + ); + messageId++; + } + + // Generate object markers + for (int i = 0; i < objectCount; i++) { + final latOffset = (_random.nextDouble() - 0.5) * 0.015; + final lonOffset = (_random.nextDouble() - 0.5) * 0.015; + final lat = centerLocation.latitude + latOffset; + final lon = centerLocation.longitude + lonOffset; + + final senderKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + final timestamp = now.subtract(Duration(minutes: 40 + i * 5)); + final notes = [ + l10n.sampleObjectBackpack, + l10n.sampleObjectVehicle, + l10n.sampleObjectCamping, + l10n.sampleObjectTrailMarker, + ]; + + final message = Message( + id: 'sample_object_$messageId', + messageType: MessageType.contact, + senderPublicKeyPrefix: senderKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, + text: + 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}', + receivedAt: timestamp, + isSarMarker: true, + sarGpsCoordinates: LatLng(lat, lon), + sarCustomEmoji: '📦', + senderName: l10n.sampleSearcher, + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.007), + receivedAt: timestamp, + source: 'telemetry', + ); + messageId++; + } + + return SampleMessageBatch( + messages: messages, + contactLocations: contactLocations, + ); + } + + /// Generate sample map drawings + static List generateDrawings({ + required LatLng centerLocation, + required AppLocalizations l10n, + }) { + final drawings = []; + final now = DateTime.now(); + + // Generate a line drawing (e.g., search path) + final linePoints = [ + LatLng(centerLocation.latitude + 0.002, centerLocation.longitude - 0.003), + LatLng(centerLocation.latitude + 0.004, centerLocation.longitude - 0.002), + LatLng(centerLocation.latitude + 0.005, centerLocation.longitude + 0.001), + LatLng(centerLocation.latitude + 0.003, centerLocation.longitude + 0.003), + ]; + + drawings.add({ + 'type': 'line', + 'id': 'sample_line_${now.millisecondsSinceEpoch}', + 'color': Colors.blue.toARGB32(), + 'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(), + 'points': linePoints + .map((p) => {'lat': p.latitude, 'lon': p.longitude}) + .toList(), + 'sender': l10n.sampleTeamMember, + }); + + // Generate a rectangle drawing (e.g., search area) + drawings.add({ + 'type': 'rectangle', + 'id': 'sample_rect_${now.millisecondsSinceEpoch + 1}', + 'color': Colors.red.toARGB32(), + 'createdAt': now.subtract(const Duration(minutes: 10)).toIso8601String(), + 'topLeft': { + 'lat': centerLocation.latitude - 0.003, + 'lon': centerLocation.longitude - 0.004, + }, + 'bottomRight': { + 'lat': centerLocation.latitude - 0.001, + 'lon': centerLocation.longitude - 0.001, + }, + 'sender': l10n.sampleScout, + }); + + return drawings; + } + + /// Generate sample channel messages for public channels + static SampleMessageBatch generateChannelMessages({ + LatLng? centerLocation, + required AppLocalizations l10n, + int generalChannelMessages = 8, + int emergencyChannelMessages = 5, + }) { + // Use provided location or default to Ljubljana, Slovenia + final center = centerLocation ?? const LatLng(46.0569, 14.5058); + final messages = []; + final contactLocations = {}; + final now = DateTime.now(); + int messageId = 1000; // Start with high ID to avoid conflicts + + // Sample messages for General channel (index 0) + final generalMessages = [ + l10n.sampleMsgAllTeamsCheckIn, + l10n.sampleMsgWeatherUpdate, + l10n.sampleMsgBaseCamp, + l10n.sampleMsgTeamAlpha, + l10n.sampleMsgRadioCheck, + l10n.sampleMsgWaterSupply, + l10n.sampleMsgTeamBravo, + l10n.sampleMsgEtaRallyPoint, + l10n.sampleMsgSupplyDrop, + l10n.sampleMsgDroneSurvey, + l10n.sampleMsgTeamCharlie, + l10n.sampleMsgRadioDiscipline, + ]; + + // Sample messages for Emergency channel (index 1) + // Mix regular messages and SAR markers + final emergencyMessages = [ + l10n.sampleMsgUrgentMedical, + 'S:🧑:${center.latitude.toStringAsFixed(5)},${(center.longitude + 0.005).toStringAsFixed(5)}${l10n.sampleMsgAdultMale}', + l10n.sampleMsgFireSpotted, + 'S:🔥:${(center.latitude + 0.008).toStringAsFixed(5)},${(center.longitude + 0.003).toStringAsFixed(5)}${l10n.sampleMsgSpreadingRapidly}', + l10n.sampleMsgPriorityHelicopter, + l10n.sampleMsgMedicalTeamEnRoute, + l10n.sampleMsgEvacHelicopter, + l10n.sampleMsgEmergencyResolved, + 'S:🏕️:${(center.latitude - 0.002).toStringAsFixed(5)},${(center.longitude - 0.004).toStringAsFixed(5)}${l10n.sampleMsgEmergencyStagingArea}', + l10n.sampleMsgEmergencyServices, + ]; + + final teamNames = [ + l10n.sampleAlphaTeamLead, + l10n.sampleBravoScout, + l10n.sampleCharlieMedic, + l10n.sampleDeltaNavigator, + l10n.sampleEchoSupport, + l10n.sampleBaseCommand, + l10n.sampleFieldCoordinator, + l10n.sampleMedicalTeam, + ]; + + // Generate General channel messages + for ( + int i = 0; + i < generalChannelMessages && i < generalMessages.length; + i++ + ) { + final senderKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + // Messages spread over the last 2 hours + final minutesAgo = 120 - (i * 15) - _random.nextInt(10); + final timestamp = now.subtract(Duration(minutes: minutesAgo)); + + final message = Message( + id: 'sample_general_$messageId', + messageType: MessageType.channel, + channelIdx: 0, // General channel + senderPublicKeyPrefix: senderKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, + text: generalMessages[i], + receivedAt: timestamp, + senderName: teamNames[_random.nextInt(teamNames.length)], + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(center, 0.018), + receivedAt: timestamp, + source: i.isEven ? 'telemetry' : 'advert', + ); + messageId++; + } + + // Generate Emergency channel messages + for ( + int i = 0; + i < emergencyChannelMessages && i < emergencyMessages.length; + i++ + ) { + final senderKey = Uint8List.fromList( + List.generate(32, (_) => _random.nextInt(256)), + ); + + // Emergency messages more recent (last hour) + final minutesAgo = 60 - (i * 10) - _random.nextInt(5); + final timestamp = now.subtract(Duration(minutes: minutesAgo)); + + final message = Message( + id: 'sample_emergency_$messageId', + messageType: MessageType.channel, + channelIdx: 1, // Emergency channel + senderPublicKeyPrefix: senderKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, + text: emergencyMessages[i], + receivedAt: timestamp, + senderName: teamNames[_random.nextInt(teamNames.length)], + ); + messages.add(message); + contactLocations[message.id] = _sampleSnapshot( + location: _randomNearbyLocation(center, 0.012), + receivedAt: timestamp, + source: i.isEven ? 'advert' : 'telemetry', + ); + messageId++; + } + + return SampleMessageBatch( + messages: messages, + contactLocations: contactLocations, + ); + } +} + +class SampleMessageBatch { + final List messages; + final Map contactLocations; + + const SampleMessageBatch({ + required this.messages, + required this.contactLocations, + }); +} diff --git a/lib/utils/sar_marker_extensions.dart b/lib/utils/sar_marker_extensions.dart new file mode 100644 index 0000000..e9ce800 --- /dev/null +++ b/lib/utils/sar_marker_extensions.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; +import '../models/sar_marker.dart'; +import '../l10n/app_localizations.dart'; + +/// Extension for SarMarkerType to provide localized display names +extension SarMarkerTypeLocalization on SarMarkerType { + /// Get localized display name for this SAR marker type + String getLocalizedName(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + switch (this) { + case SarMarkerType.foundPerson: + return l10n.foundPerson; + case SarMarkerType.fire: + return l10n.fire; + case SarMarkerType.stagingArea: + return l10n.stagingArea; + case SarMarkerType.object: + return 'Object'; // Not commonly used, keeping English for now + case SarMarkerType.unknown: + return 'Unknown'; // Not commonly used, keeping English for now + } + } +} diff --git a/lib/utils/sar_message_parser.dart b/lib/utils/sar_message_parser.dart new file mode 100644 index 0000000..ae3b195 --- /dev/null +++ b/lib/utils/sar_message_parser.dart @@ -0,0 +1,219 @@ +import 'dart:convert'; + +import 'package:latlong2/latlong.dart'; + +import '../models/map_coordinate_space.dart'; +import '../models/message.dart'; +import '../models/sar_marker.dart'; +import 'custom_map_id.dart'; + +class SarMessageParser { + static const String legacyPrefix = 'S:'; + static const String customMapPrefix = 'S2:'; + + static final RegExp _sarPatternNew = RegExp( + r'^S:([^:]+):(\d):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)', + multiLine: false, + ); + + static final RegExp _sarPatternOld = RegExp( + r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)', + multiLine: false, + ); + + static bool isSarMessage(String text) { + final lines = text.trim().split('\n'); + final firstLine = lines.isEmpty ? text.trim() : lines.first; + return firstLine.startsWith(legacyPrefix) || + firstLine.startsWith(customMapPrefix); + } + + static SarMarkerInfo? parse(String text) { + final trimmed = text.trim(); + if (trimmed.startsWith(customMapPrefix)) { + return _parseCustomMap(trimmed); + } + if (!trimmed.startsWith(legacyPrefix)) { + return null; + } + + final firstLine = trimmed.split('\n').first; + var match = _sarPatternNew.firstMatch(firstLine); + final isNewFormat = match != null; + match ??= _sarPatternOld.firstMatch(firstLine); + if (match == null) return null; + + try { + final emoji = match.group(1)!; + final colorIndex = isNewFormat ? int.parse(match.group(2)!) : null; + final latitude = double.parse(match.group(isNewFormat ? 3 : 2)!); + final longitude = double.parse(match.group(isNewFormat ? 4 : 3)!); + final inlineMessage = match.group(isNewFormat ? 5 : 4)?.trim(); + if (latitude < -90 || latitude > 90) return null; + if (longitude < -180 || longitude > 180) return null; + + final additionalNotes = extractNotes(text); + String? notes; + if (inlineMessage != null && inlineMessage.isNotEmpty) { + notes = inlineMessage; + } + if (additionalNotes != null && additionalNotes.isNotEmpty) { + notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes; + } + + return SarMarkerInfo( + type: SarMarkerType.fromEmoji(emoji), + location: LatLng(latitude, longitude), + emoji: emoji, + notes: notes, + colorIndex: colorIndex != null && colorIndex >= 0 && colorIndex <= 7 + ? colorIndex + : null, + coordinateSpace: MapCoordinateSpace.geo, + ); + } catch (_) { + return null; + } + } + + static SarMarkerInfo? _parseCustomMap(String text) { + try { + final json = + jsonDecode(text.substring(customMapPrefix.length)) + as Map; + final emoji = json['e']; + final mapId = normalizeCustomMapId(json['m'] as String?); + final rawPoint = json['p']; + if (emoji is! String || mapId == null || rawPoint is! List) { + return null; + } + final point = rawPoint.cast(); + if (point.length != 2) return null; + final colorIndex = json['c']; + final notes = json['n']; + return SarMarkerInfo( + type: SarMarkerType.fromEmoji(emoji), + location: LatLng(point[0].toDouble(), point[1].toDouble()), + emoji: emoji, + notes: notes is String && notes.isNotEmpty ? notes : null, + colorIndex: colorIndex is int ? colorIndex : null, + coordinateSpace: MapCoordinateSpace.customMap, + mapId: mapId, + ); + } catch (_) { + return null; + } + } + + static Message enhanceMessage(Message message) { + final sarInfo = parse(message.text); + if (sarInfo == null) return message; + + return message.copyWith( + isSarMarker: true, + sarGpsCoordinates: sarInfo.coordinateSpace == MapCoordinateSpace.geo + ? sarInfo.location + : null, + sarCustomMapPoint: sarInfo.coordinateSpace == MapCoordinateSpace.customMap + ? sarInfo.location + : null, + sarCustomMapId: sarInfo.mapId, + sarNotes: sarInfo.notes, + sarCustomEmoji: sarInfo.emoji, + sarColorIndex: sarInfo.colorIndex, + ); + } + + static String createSarMessage({ + required SarMarkerType type, + required LatLng location, + String? notes, + int? colorIndex, + }) { + final colorIdx = colorIndex ?? 0; + final text = + 'S:${type.emoji}:$colorIdx:${location.latitude},${location.longitude}'; + if (notes != null && notes.isNotEmpty) { + return '$text:$notes'; + } + return text; + } + + static String createCustomMapSarMessage({ + required String emoji, + required String mapId, + required LatLng point, + String? notes, + int? colorIndex, + }) { + final payload = { + 'e': emoji, + 'c': colorIndex ?? 0, + 'm': normalizeCustomMapId(mapId), + 'p': [point.latitude.round(), point.longitude.round()], + }; + if (notes != null && notes.isNotEmpty) { + payload['n'] = notes; + } + return '$customMapPrefix${jsonEncode(payload)}'; + } + + static String? extractNotes(String text) { + final trimmed = text.trim(); + final lines = trimmed.split('\n'); + if (lines.length <= 1) return null; + return lines.sublist(1).join('\n').trim(); + } + + static bool isValidFormat(String text) { + return isSarMessage(text) && parse(text) != null; + } + + static String? getFormatError(String text) { + final trimmed = text.trim(); + if (trimmed.startsWith(customMapPrefix)) { + return parse(text) == null + ? 'Invalid format for custom map SAR marker' + : null; + } + if (!trimmed.startsWith(legacyPrefix)) { + return 'SAR message must start with "S:"'; + } + + final firstLine = trimmed.split('\n').first; + if (firstLine == legacyPrefix) { + return 'Invalid format for SAR marker'; + } + final parts = firstLine.split(':'); + if (parts.length < 2 || parts[1].isEmpty) { + return 'Missing emoji'; + } + + return parse(text) == null ? 'Invalid format for SAR marker' : null; + } +} + +class SarMarkerInfo { + final SarMarkerType type; + final LatLng location; + final String emoji; + final String? notes; + final int? colorIndex; + final MapCoordinateSpace coordinateSpace; + final String? mapId; + + SarMarkerInfo({ + required this.type, + required this.location, + required this.emoji, + this.notes, + this.colorIndex, + required this.coordinateSpace, + this.mapId, + }); + + @override + String toString() { + return 'SarMarkerInfo(type: ${type.displayName}, location: $location, colorIndex: $colorIndex, space: ${coordinateSpace.name}, mapId: $mapId)'; + } +} diff --git a/lib/utils/slovenian_crs.dart b/lib/utils/slovenian_crs.dart new file mode 100644 index 0000000..935778a --- /dev/null +++ b/lib/utils/slovenian_crs.dart @@ -0,0 +1,87 @@ +import 'dart:math' show Point; +import 'dart:ui' show Rect; +import 'package:flutter_map/flutter_map.dart'; +import 'package:proj4dart/proj4dart.dart' as proj4; + +/// EPSG:3794 - Slovenia 1996 / Slovene National Grid +/// Transverse Mercator projection for Slovenia +/// +/// Official definition from https://epsg.io/3794: +/// +proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=-5000000 +/// +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs +/// +/// This CRS is used by Slovenian government WMS services (prostor.zgs.gov.si) + +/// Register and get EPSG:3794 projection +proj4.Projection getEpsg3794Projection() { + const epsg3794Def = + '+proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 ' + '+x_0=500000 +y_0=-5000000 +ellps=GRS80 ' + '+towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs'; + + // Register projection if not already registered + try { + return proj4.Projection.get('EPSG:3794') ?? + proj4.Projection.add('EPSG:3794', epsg3794Def); + } catch (e) { + // If already registered, get it + return proj4.Projection.get('EPSG:3794')!; + } +} + +/// Create Proj4Crs for EPSG:3794 +/// +/// Configuration matches the GeoWebCache tile grid used by prostor.zgs.gov.si +/// +/// Official tile grid from WMTS GetCapabilities: +/// - TopLeftCorner: 373217.6542445397, 246158.298050262 +/// - Bounds: X: 373217.65 to 695777.65, Y: 31118.30 to 246158.30 +/// - Tile size: 256x256 pixels +/// - Scale denominators converted to resolutions using: resolution = scaleDenom * 0.00028 +Crs getSlovenianCrs() { + final projection = getEpsg3794Projection(); + + // Resolutions calculated from GeoWebCache scale denominators + // Formula: resolution (m/px) = scaleDenominator * 0.00028 (OGC standard) + final resolutions = [ + 420.0, // Zoom 0 - ScaleDenom: 1500000 + 280.0, // Zoom 1 - ScaleDenom: 1000000 + 210.0, // Zoom 2 - ScaleDenom: 750000 + 140.0, // Zoom 3 - ScaleDenom: 500000 + 70.0, // Zoom 4 - ScaleDenom: 250000 + 28.0, // Zoom 5 - ScaleDenom: 100000 + 14.0, // Zoom 6 - ScaleDenom: 50000 + 7.0, // Zoom 7 - ScaleDenom: 25000 + 4.2, // Zoom 8 - ScaleDenom: 15000 + 2.8, // Zoom 9 - ScaleDenom: 10000 + 1.4, // Zoom 10 - ScaleDenom: 5000 + 0.56, // Zoom 11 - ScaleDenom: 2000 + 0.28, // Zoom 12 - ScaleDenom: 1000 + 0.14, // Zoom 13 - ScaleDenom: 500 + 0.07, // Zoom 14 - ScaleDenom: 250 + 0.028, // Zoom 15 - ScaleDenom: 100 + ]; + + // Bounds from WMS capabilities (actual data extent in Slovenia) + final bounds = Rect.fromLTRB( + 373217.65, // min X (west) + 31118.30, // min Y (south) - top in Rect coordinates + 695777.65, // max X (east) + 246158.30, // max Y (north) - bottom in Rect coordinates + ); + + // Origin from WMTS TileMatrixSet TopLeftCorner + // This is the top-left corner of the tile pyramid (min X, max Y) + final origin = Point(373217.6542445397, 246158.298050262); + + return Proj4Crs.fromFactory( + code: 'EPSG:3794', + proj4Projection: projection, + resolutions: resolutions, + bounds: bounds, + origins: [origin], + ); +} + +/// Singleton instance of Slovenian CRS for reuse +final Crs slovenianCrs = getSlovenianCrs(); diff --git a/lib/utils/tictactoe_message_parser.dart b/lib/utils/tictactoe_message_parser.dart new file mode 100644 index 0000000..9156033 --- /dev/null +++ b/lib/utils/tictactoe_message_parser.dart @@ -0,0 +1,163 @@ +enum TicTacToeEventType { start, move } + +class TicTacToeEvent { + final TicTacToeEventType type; + final String gameId; + final String playerKey6; + final int? cell; + final int timestampSec; + + const TicTacToeEvent({ + required this.type, + required this.gameId, + required this.playerKey6, + this.cell, + required this.timestampSec, + }); +} + +class TicTacToeMessageParser { + static const String _prefix = 'TTT1:'; + + static bool isTicTacToe(String text) => text.startsWith(_prefix); + + static TicTacToeEvent? tryParse(String text) { + if (!isTicTacToe(text)) return null; + final body = text.substring(_prefix.length); + final parts = body.split(':'); + if (parts.length < 4) return null; + + final action = parts[0]; + final gameId = parts[1].toLowerCase(); + if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(gameId)) return null; + + if (action == 'S' && parts.length == 4) { + final starterKey6 = parts[2].toLowerCase(); + final ts = int.tryParse(parts[3]); + if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(starterKey6)) return null; + if (ts == null || ts <= 0) return null; + return TicTacToeEvent( + type: TicTacToeEventType.start, + gameId: gameId, + playerKey6: starterKey6, + timestampSec: ts, + ); + } + + if (action == 'M' && parts.length == 5) { + final cell = int.tryParse(parts[2]); + final playerKey6 = parts[3].toLowerCase(); + final ts = int.tryParse(parts[4]); + if (cell == null || cell < 0 || cell > 8) return null; + if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(playerKey6)) return null; + if (ts == null || ts <= 0) return null; + return TicTacToeEvent( + type: TicTacToeEventType.move, + gameId: gameId, + playerKey6: playerKey6, + cell: cell, + timestampSec: ts, + ); + } + + return null; + } + + static String encodeStart({ + required String gameId, + required String starterKey6, + required int timestampSec, + }) { + return '$_prefix' + 'S:${gameId.toLowerCase()}:${starterKey6.toLowerCase()}:$timestampSec'; + } + + static String encodeMove({ + required String gameId, + required int cell, + required String playerKey6, + required int timestampSec, + }) { + return '$_prefix' + 'M:${gameId.toLowerCase()}:$cell:${playerKey6.toLowerCase()}:$timestampSec'; + } +} + +class TicTacToeGameState { + final String gameId; + final String xPlayerKey6; + final String oPlayerKey6; + final List board; // 'X' / 'O' / null + final String nextSymbol; + final String? winnerSymbol; + + const TicTacToeGameState({ + required this.gameId, + required this.xPlayerKey6, + required this.oPlayerKey6, + required this.board, + required this.nextSymbol, + this.winnerSymbol, + }); + + bool get isDraw => + winnerSymbol == null && board.every((cell) => cell != null); + bool get isFinished => winnerSymbol != null || isDraw; +} + +TicTacToeGameState buildTicTacToeState({ + required String gameId, + required String xPlayerKey6, + required String oPlayerKey6, + required List events, +}) { + final board = List.filled(9, null); + var next = 'X'; + String? winner; + + final sorted = [...events] + ..sort((a, b) => a.timestampSec.compareTo(b.timestampSec)); + + for (final event in sorted) { + if (event.type != TicTacToeEventType.move) continue; + if (winner != null) break; + + final expectedKey = next == 'X' ? xPlayerKey6 : oPlayerKey6; + final cell = event.cell; + if (cell == null) continue; + if (event.playerKey6 != expectedKey) continue; + if (board[cell] != null) continue; + + board[cell] = next; + winner = _computeWinner(board); + next = next == 'X' ? 'O' : 'X'; + } + + return TicTacToeGameState( + gameId: gameId, + xPlayerKey6: xPlayerKey6, + oPlayerKey6: oPlayerKey6, + board: board, + nextSymbol: next, + winnerSymbol: winner, + ); +} + +String? _computeWinner(List board) { + const lines = >[ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [0, 3, 6], + [1, 4, 7], + [2, 5, 8], + [0, 4, 8], + [2, 4, 6], + ]; + for (final line in lines) { + final a = board[line[0]]; + if (a == null) continue; + if (a == board[line[1]] && a == board[line[2]]) return a; + } + return null; +} diff --git a/lib/utils/time_ago_extensions.dart b/lib/utils/time_ago_extensions.dart new file mode 100644 index 0000000..54711c3 --- /dev/null +++ b/lib/utils/time_ago_extensions.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; +import '../l10n/app_localizations.dart'; + +/// Extension to provide localized "time ago" formatting +extension TimeAgoExtension on Duration { + /// Get localized time ago string + String toLocalizedTimeAgo(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + if (inMinutes < 1) return l10n.justNow; + if (inMinutes < 60) return l10n.minutesAgo(inMinutes); + if (inHours < 24) return l10n.hoursAgo(inHours); + return l10n.daysAgo(inDays); + } + + /// Get localized time ago string with seconds support + String toLocalizedTimeAgoWithSeconds(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + if (inSeconds < 60) return l10n.secondsAgo(inSeconds); + if (inMinutes < 60) return l10n.minutesAgo(inMinutes); + if (inHours < 24) return l10n.hoursAgo(inHours); + return l10n.daysAgo(inDays); + } +} diff --git a/lib/utils/toast_logger.dart b/lib/utils/toast_logger.dart new file mode 100644 index 0000000..5618028 --- /dev/null +++ b/lib/utils/toast_logger.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/messages_provider.dart'; + +/// Global messenger key wired to [MaterialApp.scaffoldMessengerKey] in main.dart +/// so toasts can be shown even when the calling context has no Scaffold. +final GlobalKey rootScaffoldMessengerKey = + GlobalKey(); + +/// Toast logger utility - shows a floating SnackBar and records a system +/// message in the channels tab history +class ToastLogger { + /// Log an info message + static void info(BuildContext context, String message) { + _log(context, message, 'info'); + } + + /// Log a success message + static void success(BuildContext context, String message) { + _log(context, message, 'success'); + } + + /// Log a warning message + static void warning(BuildContext context, String message) { + _log(context, message, 'warning'); + } + + /// Log an error message + static void error(BuildContext context, String message) { + _log(context, message, 'error'); + } + + /// Internal method to show a snackbar and log a system message + static void _log(BuildContext context, String message, String level) { + _showSnackBar(context, message, level); + + // Keep recording as a system message (feeds history/stats) + try { + final messagesProvider = context.read(); + messagesProvider.logSystemMessage(text: message, level: level); + } catch (e) { + // Provider not available - snackbar above is still shown + debugPrint('[$level] $message'); + } + } + + static void _showSnackBar(BuildContext context, String message, String level) { + final messenger = + rootScaffoldMessengerKey.currentState ?? + ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + debugPrint('[$level] $message'); + return; + } + + Color? backgroundColor; + switch (level) { + case 'error': + backgroundColor = context.mounted + ? Theme.of(context).colorScheme.error + : Colors.red; + break; + case 'warning': + backgroundColor = Colors.orange; + break; + case 'success': + backgroundColor = Colors.green; + break; + default: + backgroundColor = null; // default surface color + } + + // Don't queue rapid toasts behind each other + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + backgroundColor: backgroundColor, + duration: Duration(seconds: level == 'error' ? 4 : 3), + ), + ); + } +} diff --git a/lib/utils/transmission_target_resolver.dart b/lib/utils/transmission_target_resolver.dart new file mode 100644 index 0000000..e4617d8 --- /dev/null +++ b/lib/utils/transmission_target_resolver.dart @@ -0,0 +1,170 @@ +import 'dart:typed_data'; + +import '../models/contact.dart'; +import '../providers/contacts_provider.dart'; + +enum TransmissionTargetFailure { + unknownContact, + unknownRoute, + tooFar, + unreachable, +} + +class TransmissionTargetResolution { + final Contact? target; + final TransmissionTargetFailure? failure; + final int maxHops; + + const TransmissionTargetResolution({ + required this.target, + required this.failure, + required this.maxHops, + }); + + int get hops => target?.routeHopCount ?? -1; + bool get isValid => target != null && failure == null; +} + +class TransmissionTargetResolver { + const TransmissionTargetResolver._(); + + static Contact? resolveLocalTarget({ + required ContactsProvider contactsProvider, + required bool isSentByMe, + Uint8List? recipientPublicKey, + Uint8List? senderPublicKeyPrefix, + String? senderKey6FromEnvelope, + String? senderName, + }) { + if (isSentByMe) { + final recipient = _findByRecipientKey( + contactsProvider, + recipientPublicKey, + ); + if (recipient != null) return recipient; + } + + final byEnvelope = _findByEnvelopeKey6( + contactsProvider, + senderKey6FromEnvelope, + ); + if (byEnvelope != null) return byEnvelope; + + final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix); + if (byPrefix != null) return byPrefix; + + return _findByName(contactsProvider, senderName); + } + + static Future resolveFetchTarget({ + required ContactsProvider contactsProvider, + required Future Function() refreshContacts, + required bool isSentByMe, + Uint8List? recipientPublicKey, + Uint8List? senderPublicKeyPrefix, + String? senderKey6FromEnvelope, + String? senderName, + required int maxFetchHops, + }) async { + var target = resolveLocalTarget( + contactsProvider: contactsProvider, + isSentByMe: isSentByMe, + recipientPublicKey: recipientPublicKey, + senderPublicKeyPrefix: senderPublicKeyPrefix, + senderKey6FromEnvelope: senderKey6FromEnvelope, + senderName: senderName, + ); + + if (target == null || + !target.routeHasPath || + target.routeHopCount > maxFetchHops) { + await refreshContacts(); + target = resolveLocalTarget( + contactsProvider: contactsProvider, + isSentByMe: isSentByMe, + recipientPublicKey: recipientPublicKey, + senderPublicKeyPrefix: senderPublicKeyPrefix, + senderKey6FromEnvelope: senderKey6FromEnvelope, + senderName: senderName, + ); + } + + if (target == null) { + return TransmissionTargetResolution( + target: null, + failure: TransmissionTargetFailure.unknownContact, + maxHops: maxFetchHops, + ); + } + if (!target.routeHasPath) { + return TransmissionTargetResolution( + target: target, + failure: TransmissionTargetFailure.unknownRoute, + maxHops: maxFetchHops, + ); + } + if (target.routeHopCount > maxFetchHops) { + return TransmissionTargetResolution( + target: target, + failure: TransmissionTargetFailure.tooFar, + maxHops: maxFetchHops, + ); + } + return TransmissionTargetResolution( + target: target, + failure: null, + maxHops: maxFetchHops, + ); + } + + static Contact? _findByRecipientKey( + ContactsProvider contactsProvider, + Uint8List? recipientKey, + ) { + if (recipientKey == null || recipientKey.isEmpty) return null; + final byKey = contactsProvider.findContactByKey(recipientKey); + if (byKey != null) return byKey; + if (recipientKey.length >= 6) { + return contactsProvider.findContactByPrefix( + Uint8List.fromList(recipientKey.sublist(0, 6)), + ); + } + return null; + } + + static Contact? _findByEnvelopeKey6( + ContactsProvider contactsProvider, + String? senderKey6FromEnvelope, + ) { + if (senderKey6FromEnvelope == null || senderKey6FromEnvelope.isEmpty) { + return null; + } + return contactsProvider.findContactByPrefixHex(senderKey6FromEnvelope); + } + + static Contact? _findByPrefix( + ContactsProvider contactsProvider, + Uint8List? senderPublicKeyPrefix, + ) { + if (senderPublicKeyPrefix == null || senderPublicKeyPrefix.length < 6) { + return null; + } + return contactsProvider.findContactByPrefix( + Uint8List.fromList(senderPublicKeyPrefix.sublist(0, 6)), + ); + } + + static Contact? _findByName( + ContactsProvider contactsProvider, + String? senderName, + ) { + final normalized = senderName?.trim(); + if (normalized == null || normalized.isEmpty) return null; + for (final contact in contactsProvider.contacts) { + if (contact.advName.trim().toLowerCase() == normalized.toLowerCase()) { + return contact; + } + } + return null; + } +} diff --git a/lib/utils/voice_message_parser.dart b/lib/utils/voice_message_parser.dart new file mode 100644 index 0000000..b98ddd0 --- /dev/null +++ b/lib/utils/voice_message_parser.dart @@ -0,0 +1,725 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload +const int _voicePacketHeaderBytes = 6; // voice packet binary header in payload +const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10) +const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5) +const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz) +const int _defaultLoRaPreambleSymbols = 8; +const int _defaultLoRaCrcEnabled = 1; +const int _defaultLoRaExplicitHeader = 1; +const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle + +/// Identifies which voice codec/mode was used for a packet. +/// Matches the modeId byte in the text/binary packet header. +enum VoicePacketMode { + mode700c(0, '700C', 8000, 100, 1600), + mode1200(1, '1200', 8000, 150, 1040), + mode2400(2, '2400', 8000, 300, 520), + mode1300(3, '1300', 8000, 175, 880), + mode1400(4, '1400', 8000, 175, 880), + mode1600(5, '1600', 8000, 200, 800), + mode3200(6, '3200', 8000, 400, 400); + + const VoicePacketMode( + this.id, + this.label, + this.sampleRateHz, + this.bytesPerSecond, + this.packetDurationMs, + ); + final int id; + final String label; + final int sampleRateHz; + final int bytesPerSecond; + final int packetDurationMs; + + int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000; + + static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere( + (m) => m.id == id, + orElse: () => VoicePacketMode.mode1300, + ); +} + +/// A single Codec2-encoded chunk belonging to a multi-packet voice session. +/// +/// Text format (channels): +/// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2} +/// +/// Binary format (direct contacts, received via pushRawData): +/// [0x56 'V'][sessionId:4B][index:1B][codec2Data...] +class VoicePacket { + final String sessionId; // 8 hex chars (4 bytes) + final VoicePacketMode mode; + final int index; // 0-based + final int total; // total packet count + final Uint8List codec2Data; + + const VoicePacket({ + required this.sessionId, + required this.mode, + required this.index, + required this.total, + required this.codec2Data, + }); + + // ── Text (channel) format ──────────────────────────────────────────────── + + static const String _textPrefix = 'V:'; + + static bool isVoiceText(String text) => text.startsWith(_textPrefix); + + /// Parse a text-format voice packet. Returns null on failure. + static VoicePacket? tryParseText(String text) { + if (!text.startsWith(_textPrefix)) return null; + try { + final body = text.substring(_textPrefix.length); + final parts = body.split(':'); + // parts: [sessionId, modeId, 'idx/total', base64data] + if (parts.length != 4) return null; + + final sessionId = parts[0]; + if (sessionId.length != 8) return null; + + final modeId = int.tryParse(parts[1]); + if (modeId == null) return null; + + final indexTotal = parts[2].split('/'); + if (indexTotal.length != 2) return null; + final index = int.tryParse(indexTotal[0]); + final total = int.tryParse(indexTotal[1]); + if (index == null || total == null || total < 1) return null; + + final codec2Data = base64.decode(parts[3]); + + return VoicePacket( + sessionId: sessionId, + mode: VoicePacketMode.fromId(modeId), + index: index, + total: total, + codec2Data: codec2Data, + ); + } catch (_) { + return null; + } + } + + /// Encode as text (channel format). Max ~198 chars for 700C/1200/2400. + String encodeText() { + final b64 = base64.encode(codec2Data); + return 'V:$sessionId:${mode.id}:$index/$total:$b64'; + } + + // ── Binary format ──────────────────────────────────────────────────────── + + static const int _binaryMagic = 0x56; // 'V' + static const int _binaryHeaderLen = 6; // magic(1)+session(4)+idx(1) + + static bool isVoiceBinary(Uint8List payload) => + payload.isNotEmpty && payload[0] == _binaryMagic; + + /// Parse binary-format voice packet (from pushRawData payload). + static VoicePacket? tryParseBinary(Uint8List payload) { + if (payload.length < _binaryHeaderLen) return null; + if (payload[0] != _binaryMagic) return null; + try { + final sessionBytes = payload.sublist(1, 5); + final sessionId = sessionBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final index = payload[5]; + final codec2Data = payload.sublist(_binaryHeaderLen); + return VoicePacket( + sessionId: sessionId, + mode: VoicePacketMode.mode1300, + index: index, + total: 0, + codec2Data: codec2Data, + ); + } catch (_) { + return null; + } + } + + /// Encode as binary payload (for cmdSendRawData). + Uint8List encodeBinary() { + final sessionBytes = Uint8List(4); + for (var i = 0; i < 4; i++) { + sessionBytes[i] = int.parse( + sessionId.substring(i * 2, i * 2 + 2), + radix: 16, + ); + } + final out = Uint8List(_binaryHeaderLen + codec2Data.length); + out[0] = _binaryMagic; + out.setRange(1, 5, sessionBytes); + out[5] = index; + out.setRange(_binaryHeaderLen, out.length, codec2Data); + return out; + } + + // ── Duration helpers ───────────────────────────────────────────────────── + + /// Estimated audio duration of this packet in milliseconds. + int get durationMs { + try { + final bytesPerSecond = voiceModeBytesPerSecond(mode); + if (bytesPerSecond <= 0) return 0; + return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500); + } catch (_) { + // Be permissive with stale or malformed persisted voice metadata. + return 0; + } + } + + @override + String toString() { + final suffix = total > 0 + ? ' ${mode.label} [$index/${total - 1}]' + : ' [$index]'; + return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)'; + } +} + +/// Lightweight public/direct message envelope advertising voice availability. +/// +/// Text format: +/// VE3:{sid}:{mode}:{total}:{durS} +/// Example: +/// VE3:00112233:1:4:4 +class VoiceEnvelope { + static const String _prefix = 'VE3:'; + + final String sessionId; + final VoicePacketMode mode; + final int total; + final int durationMs; + final int version; + + const VoiceEnvelope({ + required this.sessionId, + required this.mode, + required this.total, + required this.durationMs, + this.version = 3, + }); + + static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix); + + static VoiceEnvelope? tryParseText(String text) { + if (!isVoiceEnvelopeText(text)) return null; + final body = text.substring(_prefix.length); + return _tryParse(body); + } + + static VoiceEnvelope? _tryParse(String body) { + final parts = body.split(':'); + if (parts.length != 4) return null; + try { + final sid = _decodeSessionId(parts[0]); + final mode = _parseInt(parts[1], base36: true); + final total = _parseInt(parts[2], base36: true); + final durS = _parseInt(parts[3], base36: true); + + if (sid == null) { + return null; + } + if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) { + return null; + } + if (total == null || total < 1 || total > 255) return null; + if (durS == null || durS < 0 || durS > 10 * 60) return null; + + return VoiceEnvelope( + sessionId: sid, + mode: VoicePacketMode.fromId(mode), + total: total, + durationMs: durS * 1000, + version: 3, + ); + } catch (_) { + return null; + } + } + + String encodeText() { + final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60); + return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}'; + } +} + +int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) { + VoicePacketMode.mode700c => 100, + VoicePacketMode.mode1200 => 150, + VoicePacketMode.mode2400 => 300, + VoicePacketMode.mode1300 => 175, + VoicePacketMode.mode1400 => 175, + VoicePacketMode.mode1600 => 200, + VoicePacketMode.mode3200 => 400, +}; + +/// Approximate end-to-end transmit time for a voice session over MeshCore LoRa. +/// +/// Uses envelope-level metadata (mode + duration + packet count) when only the +/// envelope is available and packet bytes are not yet received locally. +Duration estimateVoiceTransmitDuration({ + required VoicePacketMode mode, + required int packetCount, + required int durationMs, + int pathLen = 0, + int? radioBw, + int? radioSf, + int? radioCr, +}) { + if (packetCount <= 0 || durationMs <= 0) return Duration.zero; + + final bytesPerSecond = voiceModeBytesPerSecond(mode); + final totalCodecBytes = (durationMs * bytesPerSecond / 1000.0).round(); + final safePathLen = pathLen.clamp(0, 64); + final hops = safePathLen + 1; + final baseBytesPerPacket = totalCodecBytes ~/ packetCount; + final extraBytes = totalCodecBytes % packetCount; + var totalMs = 0.0; + + for (var i = 0; i < packetCount; i++) { + final codecBytes = baseBytesPerPacket + (i < extraBytes ? 1 : 0); + final loraLen = + _meshPacketHeaderBytes + + safePathLen + + _voicePacketHeaderBytes + + codecBytes; + final airtimeMs = _estimateLoRaAirtimeMs( + loraLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops; + } + + return Duration(milliseconds: totalMs.round()); +} + +/// Approximate transmit time using actually received voice packet sizes. +Duration estimateVoiceTransmitDurationFromPackets({ + required Iterable packets, + int pathLen = 0, + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final safePathLen = pathLen.clamp(0, 64); + final hops = safePathLen + 1; + var totalMs = 0.0; + + for (final packet in packets) { + if (packet == null) continue; + final loraLen = + _meshPacketHeaderBytes + + safePathLen + + _voicePacketHeaderBytes + + packet.codec2Data.length; + final airtimeMs = _estimateLoRaAirtimeMs( + loraLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + totalMs += airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops; + } + + return Duration(milliseconds: totalMs.round()); +} + +double _estimateLoRaAirtimeMs( + int payloadLenBytes, { + int? radioBw, + int? radioSf, + int? radioCr, +}) { + final sf = _normalizeSf(radioSf); + final bw = _resolveBandwidthHz(radioBw).toDouble(); + final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4); + final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1; + final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0; + + final symbolMs = ((1 << sf) / bw) * 1000.0; + final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs; + + final num = + (8 * payloadLenBytes) - + (4 * sf) + + 28 + + (16 * _defaultLoRaCrcEnabled) - + (20 * ih); + final den = 4 * (sf - (2 * de)); + final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil(); + final payloadSymbols = + 8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4); + final payloadMs = payloadSymbols * symbolMs; + + return preambleMs + payloadMs; +} + +int _normalizeSf(int? value) { + if (value == null) return _defaultLoRaSf; + if (value >= 5 && value <= 12) return value; + return _defaultLoRaSf; +} + +int _normalizeCr(int? value) { + if (value == null) return _defaultLoRaCr; + if (value >= 5 && value <= 8) return value; + return _defaultLoRaCr; +} + +int _resolveBandwidthHz(int? rawBw) { + if (rawBw == null) return _defaultLoRaBwHz; + if (rawBw > 1000) return rawBw; + switch (rawBw) { + case 0: + return 7800; + case 1: + return 10400; + case 2: + return 15600; + case 3: + return 20800; + case 4: + return 31250; + case 5: + return 41700; + case 6: + return 62500; + case 7: + return 125000; + case 8: + return 250000; + case 9: + return 500000; + default: + return _defaultLoRaBwHz; + } +} + +/// Direct control-plane request to fetch voice packets for a session. +/// +/// Text format: +/// VR3:{sid}:{want}:{requesterKey6} +/// Example: +/// VR3:00112233:a:aabbccddeeff +class VoiceFetchRequest { + static const String _prefix = 'VR3:'; + static const int _binaryMagic = 0x72; // 'r' + + final String sessionId; + final String want; + final List missingIndices; + final String requesterKey6; + final int version; + + const VoiceFetchRequest({ + required this.sessionId, + this.want = 'all', + this.missingIndices = const [], + required this.requesterKey6, + this.version = 3, + }); + + static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix); + static bool isVoiceFetchRequestBinary(Uint8List payload) => + payload.isNotEmpty && payload[0] == _binaryMagic; + + static VoiceFetchRequest? tryParseText(String text) { + if (!isVoiceFetchRequestText(text)) return null; + final body = text.substring(_prefix.length); + return _tryParse(body); + } + + static VoiceFetchRequest? tryParseBinary(Uint8List payload) { + if (!isVoiceFetchRequestBinary(payload)) return null; + if (payload.length < 13) return null; // magic+sid+flags+key6+count + try { + final sidBytes = payload.sublist(1, 5); + final sid = sidBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final flags = payload[5]; + final requesterKey6 = payload + .sublist(6, 12) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final missingCount = payload[12]; + if (payload.length != 13 + missingCount) return null; + final wantMissing = (flags & 0x01) == 0x01; + final missing = []; + for (var i = 0; i < missingCount; i++) { + missing.add(payload[13 + i]); + } + return VoiceFetchRequest( + sessionId: sid, + want: wantMissing ? 'missing' : 'all', + missingIndices: missing, + requesterKey6: requesterKey6, + version: 3, + ); + } catch (_) { + return null; + } + } + + static VoiceFetchRequest? _tryParse(String body) { + final parts = body.split(':'); + if (parts.length != 3) return null; + try { + final sid = _decodeSessionId(parts[0]); + final wantToken = parts[1]; + final requesterKey6 = parts[2]; + final normalizedWant = wantToken == 'a' + ? 'all' + : ((wantToken.startsWith('m')) ? 'missing' : wantToken); + + if (sid == null) { + return null; + } + final missingIndices = []; + if (normalizedWant == 'missing') { + final encoded = wantToken.substring(1); + if (encoded.isEmpty) return null; + missingIndices.addAll(_decodeMissingIndicesCompact(encoded)); + if (missingIndices.isEmpty) return null; + } else if (normalizedWant != 'all') { + return null; + } + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { + return null; + } + + return VoiceFetchRequest( + sessionId: sid, + want: normalizedWant, + missingIndices: missingIndices, + requesterKey6: requesterKey6.toLowerCase(), + version: 3, + ); + } catch (_) { + return null; + } + } + + String encodeText() { + final wantToken = want == 'missing' && missingIndices.isNotEmpty + ? 'm${_encodeMissingIndicesCompact(missingIndices)}' + : (want == 'all' ? 'a' : want); + return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}'; + } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) { + throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars'); + } + if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) { + throw ArgumentError.value( + requesterKey6, + 'requesterKey6', + 'Expected 12 hex chars', + ); + } + final useMissing = want == 'missing' && missingIndices.isNotEmpty; + final missing = useMissing + ? missingIndices.where((v) => v >= 0 && v <= 254).toList() + : []; + + final out = Uint8List(13 + missing.length); + out[0] = _binaryMagic; + for (var i = 0; i < 4; i++) { + out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); + } + out[5] = useMissing ? 0x01 : 0x00; + for (var i = 0; i < 6; i++) { + out[6 + i] = int.parse( + requesterKey6.substring(i * 2, i * 2 + 2), + radix: 16, + ); + } + out[12] = missing.length; + for (var i = 0; i < missing.length; i++) { + out[13 + i] = missing[i]; + } + return out; + } +} + +/// Per-fragment ACK for raw voice payload packets. +/// +/// Binary format: +/// [0x76 'v'][sessionId:4B][index:1B] +class VoiceFragmentAck { + static const int _binaryMagic = 0x76; // 'v' + + final String sessionId; // 8 hex chars + final int index; // 0..254 + + const VoiceFragmentAck({required this.sessionId, required this.index}); + + static bool isVoiceFragmentAckBinary(Uint8List payload) => + payload.length == 6 && payload[0] == _binaryMagic; + + static VoiceFragmentAck? tryParseBinary(Uint8List payload) { + if (!isVoiceFragmentAckBinary(payload)) return null; + try { + final sid = payload + .sublist(1, 5) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join() + .toLowerCase(); + final idx = payload[5]; + return VoiceFragmentAck(sessionId: sid, index: idx); + } catch (_) { + return null; + } + } + + Uint8List encodeBinary() { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) { + throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars'); + } + if (index < 0 || index > 254) { + throw ArgumentError.value(index, 'index', 'Expected 0..254'); + } + final out = Uint8List(6); + out[0] = _binaryMagic; + for (var i = 0; i < 4; i++) { + out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); + } + out[5] = index; + return out; + } +} + +int? _parseInt(String token, {required bool base36}) => + int.tryParse(token, radix: base36 ? 36 : 10); + +String _toBase36(int value) => value.toRadixString(36); + +String _encodeSessionId(String sessionIdHex) { + if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) { + throw ArgumentError.value( + sessionIdHex, + 'sessionIdHex', + 'Expected 8 hex chars', + ); + } + final value = int.parse(sessionIdHex, radix: 16); + return value.toRadixString(36); +} + +String? _decodeSessionId(String token) { + if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null; + final value = int.tryParse(token, radix: 36); + if (value == null || value < 0 || value > 0xFFFFFFFF) return null; + return value.toRadixString(16).padLeft(8, '0'); +} + +String _encodeMissingIndicesCompact(List indices) { + final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList() + ..sort(); + if (sorted.isEmpty) return ''; + final chunks = []; + var start = sorted.first; + var prev = sorted.first; + for (var i = 1; i < sorted.length; i++) { + final curr = sorted[i]; + if (curr == prev + 1) { + prev = curr; + continue; + } + chunks.add( + start == prev + ? _toBase36(start) + : '${_toBase36(start)}-${_toBase36(prev)}', + ); + start = curr; + prev = curr; + } + chunks.add( + start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', + ); + return chunks.join('.'); +} + +List _decodeMissingIndicesCompact(String encoded) { + final out = []; + for (final token in encoded.split('.')) { + if (token.isEmpty) continue; + if (!token.contains('-')) { + final value = int.tryParse(token, radix: 36); + if (value == null || value < 0 || value > 254) return const []; + out.add(value); + continue; + } + final parts = token.split('-'); + if (parts.length != 2) return const []; + final start = int.tryParse(parts[0], radix: 36); + final end = int.tryParse(parts[1], radix: 36); + if (start == null || end == null || start < 0 || end > 254 || start > end) { + return const []; + } + for (var i = start; i <= end; i++) { + out.add(i); + } + } + return out; +} + +/// Builds a compact visual waveform from real voice packet bytes. +/// +/// Note: This uses the encoded Codec2 packet bytes as the source so it works +/// even before full PCM decode/playback is available. +class VoiceWaveform { + static List buildBarsFromPackets( + Iterable packets, { + int bars = 24, + }) { + if (bars <= 0) return const []; + + final merged = []; + for (final pkt in packets) { + if (pkt == null || pkt.codec2Data.isEmpty) continue; + merged.addAll(pkt.codec2Data); + } + if (merged.isEmpty) return List.filled(bars, 0.0); + + final out = List.filled(bars, 0.0); + for (var i = 0; i < bars; i++) { + final start = (i * merged.length) ~/ bars; + var end = ((i + 1) * merged.length) ~/ bars; + if (end <= start) end = start + 1; + if (end > merged.length) end = merged.length; + + var sum = 0.0; + for (var j = start; j < end; j++) { + final centered = (merged[j] - 128).abs(); + sum += centered / 127.0; + } + out[i] = (sum / (end - start)).clamp(0.0, 1.0); + } + + // Light smoothing to avoid jittery adjacent bars. + if (bars > 2) { + final smoothed = List.from(out); + for (var i = 1; i < bars - 1; i++) { + smoothed[i] = ((out[i - 1] + out[i] + out[i + 1]) / 3.0).clamp( + 0.0, + 1.0, + ); + } + return smoothed; + } + return out; + } +} diff --git a/lib/widgets/common/bidirectional_refresh.dart b/lib/widgets/common/bidirectional_refresh.dart new file mode 100644 index 0000000..0db5d18 --- /dev/null +++ b/lib/widgets/common/bidirectional_refresh.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; + +enum _RefreshEdge { top, bottom } + +class BidirectionalRefresh extends StatefulWidget { + static const double defaultTriggerDistance = 90; + + final Widget child; + final Future Function() onRefresh; + final double triggerDistance; + + const BidirectionalRefresh({ + super.key, + required this.child, + required this.onRefresh, + this.triggerDistance = defaultTriggerDistance, + }); + + @override + State createState() => _BidirectionalRefreshState(); +} + +class _BidirectionalRefreshState extends State { + double _topPullDistance = 0; + double _bottomPullDistance = 0; + bool _isRefreshing = false; + + bool _handleScrollNotification(ScrollNotification notification) { + if (_isRefreshing) { + return false; + } + + if (notification is ScrollStartNotification) { + _resetPullDistance(); + return false; + } + + if (notification is OverscrollNotification) { + final metrics = notification.metrics; + final isAtTop = metrics.extentBefore == 0; + final isAtBottom = metrics.extentAfter == 0; + + if (!isAtTop && !isAtBottom) { + return false; + } + + var shouldRefresh = false; + setState(() { + if (isAtTop) { + _topPullDistance += notification.overscroll.abs(); + shouldRefresh = _topPullDistance >= widget.triggerDistance; + } + if (isAtBottom) { + _bottomPullDistance += notification.overscroll.abs(); + shouldRefresh = + shouldRefresh || _bottomPullDistance >= widget.triggerDistance; + } + }); + if (shouldRefresh) { + _runRefresh(); + } + return false; + } + + if (notification is ScrollEndNotification) { + _resetPullDistance(); + } + + return false; + } + + Future _runRefresh() async { + if (_isRefreshing) { + return; + } + + setState(() { + _isRefreshing = true; + }); + + try { + await widget.onRefresh(); + } finally { + if (mounted) { + setState(() { + _isRefreshing = false; + _topPullDistance = 0; + _bottomPullDistance = 0; + }); + } + } + } + + void _resetPullDistance() { + if (_topPullDistance == 0 && _bottomPullDistance == 0) { + return; + } + + setState(() { + _topPullDistance = 0; + _bottomPullDistance = 0; + }); + } + + @override + Widget build(BuildContext context) { + final topProgress = (_topPullDistance / widget.triggerDistance).clamp( + 0.0, + 1.0, + ); + final bottomProgress = (_bottomPullDistance / widget.triggerDistance).clamp( + 0.0, + 1.0, + ); + + return NotificationListener( + onNotification: _handleScrollNotification, + child: Stack( + children: [ + widget.child, + _RefreshIndicatorOverlay( + alignment: Alignment.topCenter, + progress: _isRefreshing ? 1 : topProgress, + visible: _isRefreshing || _topPullDistance > 0, + edge: _RefreshEdge.top, + ), + _RefreshIndicatorOverlay( + alignment: Alignment.bottomCenter, + progress: _isRefreshing ? 1 : bottomProgress, + visible: _isRefreshing || _bottomPullDistance > 0, + edge: _RefreshEdge.bottom, + ), + ], + ), + ); + } +} + +class _RefreshIndicatorOverlay extends StatelessWidget { + final Alignment alignment; + final double progress; + final bool visible; + final _RefreshEdge edge; + + const _RefreshIndicatorOverlay({ + required this.alignment, + required this.progress, + required this.visible, + required this.edge, + }); + + @override + Widget build(BuildContext context) { + if (!visible) { + return const SizedBox.shrink(); + } + + final indicator = RefreshProgressIndicator(value: progress); + final padding = edge == _RefreshEdge.top + ? const EdgeInsets.only(top: 12) + : const EdgeInsets.only(bottom: 12); + + return Positioned.fill( + child: IgnorePointer( + child: Align( + alignment: alignment, + child: Padding( + padding: padding, + child: SizedBox.square(dimension: 28, child: indicator), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/common/contact_avatar.dart b/lib/widgets/common/contact_avatar.dart new file mode 100644 index 0000000..28d27d0 --- /dev/null +++ b/lib/widgets/common/contact_avatar.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; + +import '../../models/contact.dart'; +import '../../services/trail_color_service.dart'; +import '../../utils/avatar_label_helper.dart'; + +class ContactAvatar extends StatelessWidget { + final Contact contact; + final double radius; + final String? displayName; + + const ContactAvatar({ + super.key, + required this.contact, + this.radius = 20, + this.displayName, + }); + + @override + Widget build(BuildContext context) { + final backgroundColor = _getBackgroundColor(context); + final foregroundColor = _getForegroundColor(backgroundColor); + final emoji = contact.roleEmoji; + + if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) { + return _buildAvatarFrame( + backgroundColor: backgroundColor, + child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)), + ); + } + + if (_shouldUseLabelFallback) { + return _buildAvatarFrame( + backgroundColor: backgroundColor, + child: Text( + AvatarLabelHelper.buildLabel(displayName ?? contact.displayName), + style: TextStyle( + color: foregroundColor, + fontSize: radius * 0.68, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + ), + ), + ); + } + + return _buildAvatarFrame( + backgroundColor: backgroundColor, + child: Icon( + _getTypeIcon(contact.type), + color: foregroundColor, + size: radius, + ), + ); + } + + Widget _buildAvatarFrame({ + required Color backgroundColor, + required Widget child, + }) { + if (_usesSquareShape) { + return Container( + width: radius * 2, + height: radius * 2, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(radius * 0.6), + ), + alignment: Alignment.center, + child: child, + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: backgroundColor, + child: child, + ); + } + + bool get _shouldUseLabelFallback => + contact.type == ContactType.chat || + contact.type == ContactType.channel || + contact.type == ContactType.room; + + bool get _usesSquareShape => + contact.type == ContactType.channel || contact.type == ContactType.room; + + bool get _showsLeadingEmoji { + final emoji = contact.roleEmoji; + if (emoji == null || emoji.isEmpty) return false; + + final effectiveName = (displayName ?? contact.displayName).trimLeft(); + return effectiveName.startsWith(emoji); + } + + Color _getBackgroundColor(BuildContext context) { + if (_shouldUseLabelFallback || _showsLeadingEmoji) { + return TrailColorService.getTrailColor(contact); + } + + switch (contact.type) { + case ContactType.none: + return Theme.of(context).colorScheme.surfaceContainerHighest; + case ContactType.chat: + return Colors.blue; + case ContactType.repeater: + return Colors.orange; + case ContactType.room: + return Colors.purple; + case ContactType.sensor: + return Colors.green; + case ContactType.channel: + return Colors.teal; + } + } + + Color _getForegroundColor(Color backgroundColor) { + return ThemeData.estimateBrightnessForColor(backgroundColor) == + Brightness.dark + ? Colors.white + : Colors.black87; + } + + IconData _getTypeIcon(ContactType type) { + switch (type) { + case ContactType.none: + return Icons.help_outline; + case ContactType.chat: + return Icons.person; + case ContactType.repeater: + return Icons.router; + case ContactType.room: + return Icons.meeting_room; + case ContactType.sensor: + return Icons.sensors; + case ContactType.channel: + return Icons.public; + } + } +} diff --git a/lib/widgets/common/location_display.dart b/lib/widgets/common/location_display.dart new file mode 100644 index 0000000..95af749 --- /dev/null +++ b/lib/widgets/common/location_display.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:latlong2/latlong.dart'; +import '../../l10n/app_localizations.dart'; +import '../../utils/location_formats.dart'; + +/// Reusable location display widget with tap-to-show modal +/// Shows coordinates in a compact format with ability to view all formats +class LocationDisplay extends StatelessWidget { + final LatLng location; + final bool compact; + + const LocationDisplay({ + super.key, + required this.location, + this.compact = true, + }); + + @override + Widget build(BuildContext context) { + if (compact) { + return GestureDetector( + onTap: () => _showLocationFormats(context), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.location_on, size: 18), + const SizedBox(width: 6), + Text( + '${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), + Icon( + Icons.open_in_new, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ); + } + + // Non-compact version (just text) + return Text( + '${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ); + } + + void _showLocationFormats(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SingleChildScrollView( + child: Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Location Formats', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + // Decimal Degrees (DD) + _buildFormatRow( + context, + 'DD (Decimal Degrees)', + '${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}', + ), + // Degrees Minutes Seconds (DMS) + _buildFormatRow( + context, + 'DMS (Degrees Minutes Seconds)', + _convertToDMS(location.latitude, location.longitude), + ), + // Degrees Decimal Minutes (DDM) + _buildFormatRow( + context, + 'DDM (Degrees Decimal Minutes)', + _convertToDDM(location.latitude, location.longitude), + ), + // MGRS (Military Grid Reference System) + _buildFormatRow( + context, + 'MGRS (Military Grid)', + _convertToMGRS(location.latitude, location.longitude), + ), + // Google Plus Code + _buildFormatRow( + context, + 'Plus Code', + formatPlusCode(location.latitude, location.longitude), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + + Widget _buildFormatRow(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.copiedToClipboard(label), + ), + duration: const Duration(seconds: 2), + ), + ); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Icons.copy, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ), + ], + ), + ); + } + + /// Convert to Degrees Minutes Seconds (DMS) format + String _convertToDMS(double lat, double lon) { + String latDir = lat >= 0 ? 'N' : 'S'; + String lonDir = lon >= 0 ? 'E' : 'W'; + + lat = lat.abs(); + lon = lon.abs(); + + int latDeg = lat.floor(); + double latMinDec = (lat - latDeg) * 60; + int latMin = latMinDec.floor(); + double latSec = (latMinDec - latMin) * 60; + + int lonDeg = lon.floor(); + double lonMinDec = (lon - lonDeg) * 60; + int lonMin = lonMinDec.floor(); + double lonSec = (lonMinDec - lonMin) * 60; + + return '$latDeg°$latMin\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°$lonMin\'${lonSec.toStringAsFixed(2)}"$lonDir'; + } + + /// Convert to Degrees Decimal Minutes (DDM) format + String _convertToDDM(double lat, double lon) { + String latDir = lat >= 0 ? 'N' : 'S'; + String lonDir = lon >= 0 ? 'E' : 'W'; + + lat = lat.abs(); + lon = lon.abs(); + + int latDeg = lat.floor(); + double latMin = (lat - latDeg) * 60; + + int lonDeg = lon.floor(); + double lonMin = (lon - lonDeg) * 60; + + return '$latDeg° ${latMin.toStringAsFixed(4)}\'$latDir, $lonDeg° ${lonMin.toStringAsFixed(4)}\'$lonDir'; + } + + /// Convert to MGRS (Military Grid Reference System) format + /// Simplified implementation - returns approximate grid zone + String _convertToMGRS(double lat, double lon) { + // Zone number (1-60) + int zone = ((lon + 180) / 6).floor() + 1; + + // Zone letter (C-X, excluding I and O) + const letters = 'CDEFGHJKLMNPQRSTUVWX'; + int letterIndex = ((lat + 80) / 8).floor(); + if (letterIndex < 0) letterIndex = 0; + if (letterIndex >= letters.length) letterIndex = letters.length - 1; + String letter = letters[letterIndex]; + + // Simplified - just show zone designation + // Full MGRS would require UTM conversion library + return '$zone$letter (approximate)'; + } +} diff --git a/lib/widgets/compact_signal_indicator.dart b/lib/widgets/compact_signal_indicator.dart new file mode 100644 index 0000000..48cd5b7 --- /dev/null +++ b/lib/widgets/compact_signal_indicator.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:meshcore_client/meshcore_client.dart' show LogRxDataInfo; + +class SignalMetric { + final String valueLabel; + final Color color; + final int activeBars; + + const SignalMetric({ + required this.valueLabel, + required this.color, + required this.activeBars, + }); + + static SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) { + if (rxInfo == null) return null; + return fromValues(rssiDbm: rxInfo.rssiDbm, snrDb: rxInfo.snrDb); + } + + static SignalMetric? fromValues({int? rssiDbm, double? snrDb}) { + if (snrDb != null) { + return SignalMetric( + valueLabel: '${snrDb.toStringAsFixed(1)}dB', + color: snrDb >= 10 + ? Colors.green + : snrDb >= 0 + ? Colors.amber + : Colors.redAccent, + activeBars: snrDb >= 10 + ? 3 + : snrDb >= 0 + ? 2 + : 1, + ); + } + if (rssiDbm != null) { + return SignalMetric( + valueLabel: '$rssiDbm dBm', + color: rssiDbm >= -80 + ? Colors.green + : rssiDbm >= -95 + ? Colors.amber + : Colors.redAccent, + activeBars: rssiDbm >= -80 + ? 3 + : rssiDbm >= -95 + ? 2 + : 1, + ); + } + return null; + } +} + +class CompactSignalIndicator extends StatelessWidget { + final SignalMetric metric; + final bool dense; + + const CompactSignalIndicator({ + super.key, + required this.metric, + this.dense = false, + }); + + @override + Widget build(BuildContext context) { + final inactive = Theme.of(context).colorScheme.outlineVariant; + final barWidth = dense ? 4.0 : 5.0; + final baseHeight = dense ? 8.0 : 10.0; + final barStep = dense ? 6.0 : 8.0; + final barSpacing = dense ? 2.0 : 3.0; + final labelGap = dense ? 4.0 : 6.0; + final labelFontSize = dense ? 10.0 : 12.0; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var index = 0; index < 3; index++) ...[ + if (index > 0) SizedBox(width: barSpacing), + Container( + width: barWidth, + height: baseHeight + (index * barStep), + decoration: BoxDecoration( + color: index < metric.activeBars ? metric.color : inactive, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ], + ), + SizedBox(height: labelGap), + Text( + metric.valueLabel, + style: TextStyle( + fontSize: labelFontSize, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart new file mode 100644 index 0000000..8f1d97d --- /dev/null +++ b/lib/widgets/connection_dialog.dart @@ -0,0 +1,1333 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/app_localizations.dart'; +import '../providers/app_provider.dart'; +import '../providers/connection_provider.dart'; +import '../providers/contacts_provider.dart'; +import '../screens/discovery_screen.dart'; +import '../services/network_scanner_service.dart'; +import '../services/profile_workspace_coordinator.dart'; +import '../services/recent_tcp_connections_service.dart'; +import '../services/serial/serial_transport.dart'; + +enum _ConnectionDialogResult { connected } + +class _ManualTcpEndpoint { + final String host; + final int port; + + const _ManualTcpEndpoint({required this.host, required this.port}); +} + +Future _initializeConnectedWorkspace({ + required ProfileWorkspaceCoordinator profileWorkspaceCoordinator, + required AppProvider appProvider, +}) async { + await profileWorkspaceCoordinator.syncActiveProfileForCurrentDevice(); + await appProvider.initialize(); +} + +String _normalizeConnectionError(Object error) { + var message = error.toString(); + if (message.startsWith('Exception: ')) { + message = message.substring('Exception: '.length); + } + if (message.startsWith('Connection failed: Exception: ')) { + return message.substring('Connection failed: Exception: '.length); + } + if (message.startsWith('Connection failed: ')) { + return message.substring('Connection failed: '.length); + } + return message; +} + +void _showConnectionErrorSnackBar(BuildContext context, Object error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_normalizeConnectionError(error)), + backgroundColor: Colors.red, + duration: const Duration(seconds: 5), + ), + ); +} + +Future showConnectionDialogFlow( + BuildContext context, { + Color? backgroundColor, + bool offerPostConnectRepeaterDiscovery = false, +}) async { + final result = await showModalBottomSheet<_ConnectionDialogResult>( + context: context, + isScrollControlled: true, + backgroundColor: backgroundColor, + builder: (context) => const ConnectionDialog(), + ); + + if (result != _ConnectionDialogResult.connected || !context.mounted) { + return result == _ConnectionDialogResult.connected; + } + + try { + await _initializeConnectedWorkspace( + profileWorkspaceCoordinator: context.read(), + appProvider: context.read(), + ); + } catch (error) { + if (context.mounted) { + _showConnectionErrorSnackBar(context, error); + } + } + + if (!context.mounted) { + return true; + } + + if (!offerPostConnectRepeaterDiscovery) { + return true; + } + + final contactsProvider = context.read(); + if (contactsProvider.repeaters.isNotEmpty) { + return true; + } + + final l10n = AppLocalizations.of(context)!; + final openDiscovery = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.postConnectDiscoveryTitle), + content: Text(l10n.postConnectDiscoveryDescription), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.continue_), + ), + FilledButton.icon( + onPressed: () => Navigator.of(dialogContext).pop(true), + icon: const Icon(Icons.router_outlined), + label: Text(l10n.discoverRepeaters), + ), + ], + ), + ); + + if (openDiscovery != true || !context.mounted) { + return true; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + const DiscoveryScreen(autoDiscoverRepeatersOnOpen: true), + ), + ); + return true; +} + +/// Connection Dialog with tabs for BLE devices and Network servers +class ConnectionDialog extends StatefulWidget { + final NetworkScannerService? networkScanner; + + const ConnectionDialog({super.key, this.networkScanner}); + + @override + State createState() => _ConnectionDialogState(); +} + +class _ConnectionDialogState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + late final ConnectionProvider _connectionProvider; + late final NetworkScannerService _networkScanner; + final List _discoveredServers = []; + List _recentServers = const []; + int _scannedCount = 0; + int _totalToScan = 0; + int _lastTabIndex = 0; + bool _hasRequestedBleScan = false; + String? _connectingToServerKey; + String? _connectingBleDeviceId; + + void _onTabChanged() { + if (_tabController.index == _lastTabIndex) return; + _lastTabIndex = _tabController.index; + + if (_tabController.index == 1) { + if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) { + setState(() { + _discoveredServers.addAll(_networkScanner.cachedServers); + }); + } else if (!_networkScanner.isScanning && + !_networkScanner.hasCachedResults) { + _startNetworkScan(); + } + } + } + + Future _loadRecentServers() async { + final recentServers = await RecentTcpConnectionsService.load(); + if (!mounted) { + return; + } + setState(() { + _recentServers = recentServers; + }); + } + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + _connectionProvider = Provider.of( + context, + listen: false, + ); + _networkScanner = widget.networkScanner ?? NetworkScannerService(); + + _networkScanner.onServerDiscovered = (server) { + if (!mounted) return; + setState(() { + if (!_discoveredServers.contains(server)) { + _discoveredServers.add(server); + } + }); + }; + + _networkScanner.onProgressUpdate = (scanned, total) { + if (!mounted) return; + setState(() { + _scannedCount = scanned; + _totalToScan = total; + }); + }; + + _tabController.addListener(_onTabChanged); + _loadRecentServers(); + } + + @override + void dispose() { + _connectionProvider.stopScan(); + _networkScanner.stopScan(); + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + super.dispose(); + } + + void _startNetworkScan() { + _networkScanner.stopScan(); + setState(() { + _discoveredServers.clear(); + _scannedCount = 0; + _totalToScan = 0; + }); + _networkScanner.clearCache(); + _networkScanner.scan(); + } + + Future _refreshBleDevices() async { + if (!_hasRequestedBleScan && mounted) { + setState(() { + _hasRequestedBleScan = true; + }); + } else { + _hasRequestedBleScan = true; + } + await _connectionProvider.stopScan(); + if (!mounted) return; + await _connectionProvider.startScan(); + } + + Color _getSignalColor(int rssi) { + if (rssi >= -60) return Colors.green; + if (rssi >= -75) return Colors.orange; + return Colors.red; + } + + void _closeOnSuccessfulConnection() { + if (!mounted) return; + Navigator.of(context).pop(_ConnectionDialogResult.connected); + } + + void _showConnectionError(Object error) { + if (!mounted) return; + _showConnectionErrorSnackBar(context, error); + } + + Future _rememberRecentServer({ + required String name, + required String host, + required int port, + }) async { + final recentServers = await RecentTcpConnectionsService.remember( + name: name, + host: host, + port: port, + ); + if (!mounted) { + return; + } + setState(() { + _recentServers = recentServers; + }); + } + + Future _connectTcpEndpoint({ + required String host, + required int port, + required String name, + required String serverKey, + }) async { + final connectionProvider = context.read(); + final l10n = AppLocalizations.of(context)!; + + setState(() { + _connectingToServerKey = serverKey; + }); + + try { + final success = await connectionProvider.connectTcp(host, port); + if (!success) { + throw Exception( + connectionProvider.error ?? l10n.failedToConnectToHost(host, port), + ); + } + await _rememberRecentServer(name: name, host: host, port: port); + _closeOnSuccessfulConnection(); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _connectingToServerKey = null; + }); + _showConnectionError(error); + } + } + + @override + Widget build(BuildContext context) { + final connectionProvider = context.watch(); + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + + return Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + child: Column( + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.35, + ), + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + children: [ + Text( + l10n.connectDevice, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + l10n.chooseTransportSubtitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 48), + ], + ), + const SizedBox(height: 12), + TabBar( + controller: _tabController, + dividerColor: Colors.transparent, + indicator: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + indicatorSize: TabBarIndicatorSize.tab, + labelColor: theme.colorScheme.onPrimaryContainer, + unselectedLabelColor: theme.colorScheme.onSurfaceVariant, + tabs: [ + Tab( + text: l10n.ble, + icon: const Icon(Icons.bluetooth_rounded), + ), + Tab( + text: l10n.network, + icon: const Icon(Icons.wifi_rounded), + ), + Tab(text: l10n.serial, icon: const Icon(Icons.usb_rounded)), + ], + ), + ], + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildBleDevicesTab(connectionProvider), + _buildNetworkServersTab(), + _buildUsbTab(), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSectionBanner({ + required IconData icon, + required String message, + required VoidCallback onRefresh, + IconData? secondaryActionIcon, + String? secondaryActionTooltip, + VoidCallback? onSecondaryAction, + }) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon(icon, color: theme.colorScheme.onPrimaryContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (secondaryActionIcon != null) + IconButton( + tooltip: secondaryActionTooltip, + icon: Icon( + secondaryActionIcon, + color: theme.colorScheme.onPrimaryContainer, + ), + onPressed: onSecondaryAction, + ), + IconButton( + icon: Icon( + Icons.refresh_rounded, + color: theme.colorScheme.onPrimaryContainer, + ), + onPressed: onRefresh, + ), + ], + ), + ], + ), + ); + } + + Future<_ManualTcpEndpoint?> _promptForManualTcpHost() async { + return showDialog<_ManualTcpEndpoint>( + context: context, + builder: (dialogContext) => _ManualTcpHostDialog( + initialHost: _connectionProvider.tcpHost, + initialPort: NetworkScannerService.defaultPort, + ), + ); + } + + Future _connectManualTcpHost() async { + if (_networkScanner.isScanning) { + _networkScanner.stopScan(); + if (mounted) { + setState(() {}); + } + } + + final endpoint = await _promptForManualTcpHost(); + if (endpoint == null || !mounted) { + return; + } + + await _connectTcpEndpoint( + host: endpoint.host, + port: endpoint.port, + name: endpoint.host, + serverKey: '${endpoint.host}:${endpoint.port}', + ); + } + + Widget _buildErrorBanner(String message) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.error_outline_rounded, + color: theme.colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState({ + required IconData icon, + required String title, + required String actionLabel, + required VoidCallback onAction, + }) { + final theme = Theme.of(context); + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 36, + color: theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.8, + ), + ), + ), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + FilledButton.tonalIcon( + onPressed: onAction, + icon: const Icon(Icons.refresh_rounded), + label: Text(actionLabel), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildTransportCard({ + required IconData icon, + required Color iconColor, + required String title, + required String subtitle, + required Widget trailing, + VoidCallback? onTap, + bool enabled = true, + }) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: enabled ? onTap : null, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: iconColor), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + trailing, + ], + ), + ), + ), + ); + } + + Widget _buildNetworkSectionHeader(String label) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 6), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) { + final l10n = AppLocalizations.of(context)!; + + return Column( + children: [ + _buildSectionBanner( + icon: Icons.bluetooth_searching_rounded, + message: l10n.defaultPinInfo, + onRefresh: _refreshBleDevices, + ), + if (connectionProvider.error != null) + _buildErrorBanner(connectionProvider.error!), + Expanded( + child: + connectionProvider.isScanning && + connectionProvider.scannedDevices.isEmpty + ? const Center(child: CircularProgressIndicator()) + : connectionProvider.scannedDevices.isEmpty + ? _buildEmptyState( + icon: Icons.bluetooth_searching_rounded, + title: _hasRequestedBleScan + ? l10n.noDevicesFound + : l10n.pressScanToSearchForDevices, + actionLabel: _hasRequestedBleScan + ? l10n.scanAgain + : l10n.scan, + onAction: _refreshBleDevices, + ) + : ListView.builder( + itemCount: connectionProvider.scannedDevices.length, + itemBuilder: (context, index) { + final scannedDevice = + connectionProvider.scannedDevices[index]; + final device = scannedDevice.device; + final rssi = scannedDevice.rssi; + final signalColor = _getSignalColor(rssi); + final deviceId = device.remoteId.toString(); + final isConnecting = _connectingBleDeviceId == deviceId; + + Future connectBle() async { + setState(() { + _connectingBleDeviceId = deviceId; + }); + try { + final success = await connectionProvider.connect( + device, + ); + if (!success) { + final name = device.platformName.isNotEmpty + ? device.platformName + : l10n.device; + throw Exception( + connectionProvider.error ?? + l10n.failedToConnectToDevice(name), + ); + } + _closeOnSuccessfulConnection(); + } catch (error) { + _showConnectionError(error); + } finally { + if (mounted) { + setState(() { + _connectingBleDeviceId = null; + }); + } + } + } + + return _buildTransportCard( + icon: Icons.bluetooth_rounded, + iconColor: signalColor, + title: device.platformName.isNotEmpty + ? device.platformName + : l10n.unknownDevice, + subtitle: AppLocalizations.of(context)!.signalDbm(rssi.toString()), + trailing: isConnecting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + ), + ) + : FilledButton.tonal( + onPressed: connectBle, + child: Text(AppLocalizations.of(context)!.connect), + ), + onTap: isConnecting ? null : connectBle, + enabled: !isConnecting, + ); + }, + ), + ), + ], + ); + } + + Widget _buildNetworkServersTab() { + final l10n = AppLocalizations.of(context)!; + final bool showingCachedResults = + !_networkScanner.isScanning && + _networkScanner.hasCachedResults && + _discoveredServers.isNotEmpty; + final bool hasRecentServers = _recentServers.isNotEmpty; + final bool hasDiscoveredServers = _discoveredServers.isNotEmpty; + final bool showEmptyState = + !_networkScanner.isScanning && + !hasRecentServers && + !hasDiscoveredServers; + final bool isAnyConnectionInProgress = _connectingToServerKey != null; + + return Column( + children: [ + _buildSectionBanner( + icon: showingCachedResults + ? Icons.cached_rounded + : Icons.wifi_find_rounded, + message: showingCachedResults + ? l10n.showingCachedResultsTapRefresh + : l10n.scanningLocalNetworkOnPort( + NetworkScannerService.defaultPort, + ), + secondaryActionIcon: Icons.add_rounded, + secondaryActionTooltip: _networkScanner.isScanning + ? l10n.cancelScanAndAddServer + : l10n.addServer, + onSecondaryAction: isAnyConnectionInProgress + ? null + : _connectManualTcpHost, + onRefresh: _startNetworkScan, + ), + if (_networkScanner.isScanning) + Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + LinearProgressIndicator( + value: _totalToScan > 0 ? _scannedCount / _totalToScan : null, + ), + const SizedBox(height: 8), + Text( + l10n.scanningProgressIps( + _scannedCount, + _totalToScan > 0 ? '$_totalToScan' : '?', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _connectManualTcpHost, + icon: const Icon(Icons.close_rounded), + label: Text(l10n.cancelAndEnterManually), + ), + ), + ], + ), + ), + Expanded( + child: showEmptyState + ? _buildEmptyState( + icon: Icons.wifi_off_rounded, + title: l10n.noRecentOrDiscoveredServers, + actionLabel: l10n.scanAgain, + onAction: _startNetworkScan, + ) + : ListView( + children: [ + if (hasRecentServers) + _buildNetworkSectionHeader(l10n.recentlyUsed), + for (final server in _recentServers) + _buildTransportCard( + icon: Icons.history_rounded, + iconColor: Theme.of(context).colorScheme.primary, + title: server.name, + subtitle: + _connectingToServerKey == '${server.host}:${server.port}' + ? l10n.connecting + : '${server.host}:${server.port}', + trailing: + _connectingToServerKey == '${server.host}:${server.port}' + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + ), + ) + : FilledButton.tonal( + onPressed: isAnyConnectionInProgress + ? null + : () => _connectTcpEndpoint( + host: server.host, + port: server.port, + name: server.name, + serverKey: '${server.host}:${server.port}', + ), + child: Text(AppLocalizations.of(context)!.connect), + ), + enabled: !isAnyConnectionInProgress, + onTap: isAnyConnectionInProgress + ? null + : () => _connectTcpEndpoint( + host: server.host, + port: server.port, + name: server.name, + serverKey: '${server.host}:${server.port}', + ), + ), + if (hasDiscoveredServers) + _buildNetworkSectionHeader( + l10n.discoveredOnThisNetwork, + ), + for (final server in _discoveredServers) + Builder( + builder: (context) { + final serverKey = '${server.ipAddress}:${server.port}'; + final isConnectingToThisServer = + _connectingToServerKey == serverKey; + + Future connectServer() async { + try { + final isAvailable = await _networkScanner.verifyServer( + server, + ); + if (!isAvailable) { + throw Exception( + l10n.serverNoLongerAvailable( + server.ipAddress, + server.port, + ), + ); + } + + await _connectTcpEndpoint( + host: server.ipAddress, + port: server.port, + name: server.displayName, + serverKey: serverKey, + ); + } catch (e) { + _showConnectionError(e); + } + } + + return _buildTransportCard( + icon: Icons.wifi_rounded, + iconColor: Colors.green, + title: server.displayName, + subtitle: isConnectingToThisServer + ? l10n.connecting + : '${server.ipAddress}:${server.port} • ${server.responseTime}ms', + trailing: isConnectingToThisServer + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + ), + ) + : FilledButton.tonal( + onPressed: isAnyConnectionInProgress + ? null + : connectServer, + child: Text(AppLocalizations.of(context)!.connect), + ), + enabled: !isAnyConnectionInProgress, + onTap: isAnyConnectionInProgress ? null : connectServer, + ); + }, + ), + if (_networkScanner.isScanning && !hasDiscoveredServers) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ), + ], + ), + ), + ], + ); + } + + Widget _buildUsbTab() { + return _SerialDeviceList( + buildTransportCard: + ({ + required icon, + required iconColor, + required title, + required subtitle, + required trailing, + onTap, + enabled = true, + }) => _buildTransportCard( + icon: icon, + iconColor: iconColor, + title: title, + subtitle: subtitle, + trailing: trailing, + onTap: onTap, + enabled: enabled, + ), + buildEmptyState: + ({ + required icon, + required title, + required actionLabel, + required onAction, + }) => _buildEmptyState( + icon: icon, + title: title, + actionLabel: actionLabel, + onAction: onAction, + ), + onConnected: (result) { + if (mounted) { + Navigator.of(context).pop(result); + } + }, + ); + } +} + +class _ManualTcpHostDialog extends StatefulWidget { + final String? initialHost; + final int initialPort; + + const _ManualTcpHostDialog({ + this.initialHost, + required this.initialPort, + }); + + @override + State<_ManualTcpHostDialog> createState() => _ManualTcpHostDialogState(); +} + +class _ManualTcpHostDialogState extends State<_ManualTcpHostDialog> { + late final TextEditingController _hostController; + late final TextEditingController _portController; + String? _hostErrorText; + String? _portErrorText; + + @override + void initState() { + super.initState(); + _hostController = TextEditingController(text: widget.initialHost); + _portController = TextEditingController(text: widget.initialPort.toString()); + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + super.dispose(); + } + + void _submit() { + final l10n = AppLocalizations.of(context)!; + final host = _hostController.text.trim(); + final portText = _portController.text.trim(); + final parsedAddress = InternetAddress.tryParse(host); + final parsedPort = int.tryParse(portText); + String? hostErrorText; + String? portErrorText; + + if (parsedAddress == null) { + hostErrorText = l10n.enterValidIpAddress; + } + if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) { + portErrorText = l10n.enterValidTcpPort; + } + if (hostErrorText != null || portErrorText != null) { + setState(() { + _hostErrorText = hostErrorText; + _portErrorText = portErrorText; + }); + return; + } + Navigator.of( + context, + ).pop(_ManualTcpEndpoint(host: parsedAddress!.address, port: parsedPort!)); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.connectByIpAddress), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _hostController, + autofocus: true, + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.ipAddress, + hintText: '192.168.1.42', + border: const OutlineInputBorder(), + errorText: _hostErrorText, + ), + onChanged: (_) { + if (_hostErrorText == null) { + return; + } + setState(() { + _hostErrorText = null; + }); + }, + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 12), + TextField( + controller: _portController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.tcpPort, + hintText: NetworkScannerService.defaultPort.toString(), + helperText: AppLocalizations.of(context)!.customServerPort, + border: const OutlineInputBorder(), + errorText: _portErrorText, + ), + onChanged: (_) { + if (_portErrorText == null) { + return; + } + setState(() { + _portErrorText = null; + }); + }, + onSubmitted: (_) => _submit(), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: _submit, + child: Text(AppLocalizations.of(context)!.connect), + ), + ], + ); + } +} + +typedef _TransportCardBuilder = + Widget Function({ + required IconData icon, + required Color iconColor, + required String title, + required String subtitle, + required Widget trailing, + VoidCallback? onTap, + bool enabled, + }); + +typedef _EmptyStateBuilder = + Widget Function({ + required IconData icon, + required String title, + required String actionLabel, + required VoidCallback onAction, + }); + +class _SerialDeviceList extends StatefulWidget { + final ValueChanged<_ConnectionDialogResult> onConnected; + final _TransportCardBuilder buildTransportCard; + final _EmptyStateBuilder buildEmptyState; + + const _SerialDeviceList({ + required this.onConnected, + required this.buildTransportCard, + required this.buildEmptyState, + }); + + @override + State<_SerialDeviceList> createState() => _SerialDeviceListState(); +} + +class _SerialDeviceListState extends State<_SerialDeviceList> { + final SerialTransport _transport = createSerialTransport(); + List _devices = []; + bool _isScanning = false; + bool _isConnecting = false; + + @override + void initState() { + super.initState(); + if (_transport.isSupported) { + _scanDevices(); + } + } + + @override + void dispose() { + _transport.dispose(); + super.dispose(); + } + + Future _scanDevices() async { + if (!_transport.isSupported) { + return; + } + setState(() => _isScanning = true); + try { + final devices = await _transport.listDevices(); + if (!mounted) return; + setState(() { + _devices = devices; + _isScanning = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _devices = []; + _isScanning = false; + }); + } + } + + Future _requestDevice() async { + if (!_transport.canRequestDevice) { + await _scanDevices(); + return; + } + + setState(() => _isScanning = true); + try { + final selectedDevice = await _transport.requestDevice(); + final devices = await _transport.listDevices(); + if (!mounted) return; + setState(() { + _devices = selectedDevice == null + ? devices + : _mergeDevices(devices, selectedDevice); + _isScanning = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _isScanning = false; + }); + } + } + + List _mergeDevices( + List devices, + SerialDeviceInfo selectedDevice, + ) { + final merged = [...devices]; + if (!merged.any((device) => device.id == selectedDevice.id)) { + merged.insert(0, selectedDevice); + } + return merged; + } + + Future _connectToDevice(SerialDeviceInfo device) async { + setState(() => _isConnecting = true); + try { + final connectionProvider = context.read(); + final connection = await _transport.connect(device); + final success = await connectionProvider.connectSerial( + service: connection.service, + disconnectTransport: connection.disconnect, + deviceId: connection.deviceId, + deviceName: connection.deviceName, + ); + if (!mounted) return; + + if (success) { + widget.onConnected(_ConnectionDialogResult.connected); + } else { + await connection.disconnect(); + connection.service.dispose(); + if (!mounted) return; + setState(() => _isConnecting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.failedToConnectViaSerial)), + ); + } + } catch (e) { + if (!mounted) return; + setState(() => _isConnecting = false); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.serialError(e.toString())))); + } + } + + @override + Widget build(BuildContext context) { + if (!_transport.isSupported) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + _transport.unsupportedMessage, + textAlign: TextAlign.center, + ), + ), + ); + } + + if (_isScanning) { + return const Center(child: CircularProgressIndicator()); + } + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: FilledButton.tonalIcon( + onPressed: _isConnecting + ? null + : (_transport.canRequestDevice ? _requestDevice : _scanDevices), + icon: const Icon(Icons.usb_rounded), + label: Text(_transport.actionLabel), + ), + ), + if (_devices.isEmpty) + Expanded( + child: widget.buildEmptyState( + icon: Icons.usb_off_rounded, + title: _transport.emptyStateTitle, + actionLabel: _transport.actionLabel, + onAction: _transport.canRequestDevice + ? _requestDevice + : _scanDevices, + ), + ) + else + Expanded( + child: ListView.builder( + itemCount: _devices.length, + itemBuilder: (context, index) { + final device = _devices[index]; + return widget.buildTransportCard( + icon: Icons.usb_rounded, + iconColor: Theme.of(context).colorScheme.primary, + title: device.title, + subtitle: device.subtitle, + trailing: _isConnecting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2.5), + ) + : FilledButton.tonal( + onPressed: _isConnecting + ? null + : () => _connectToDevice(device), + child: Text(AppLocalizations.of(context)!.connect), + ), + enabled: !_isConnecting, + onTap: _isConnecting ? null : () => _connectToDevice(device), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/contacts/add_channel_dialog.dart b/lib/widgets/contacts/add_channel_dialog.dart new file mode 100644 index 0000000..822eacd --- /dev/null +++ b/lib/widgets/contacts/add_channel_dialog.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import '../../l10n/app_localizations.dart'; + +/// Bottom sheet for adding a new channel +class AddChannelSheet extends StatefulWidget { + final Future Function(String name, String secret) onCreateChannel; + + const AddChannelSheet({super.key, required this.onCreateChannel}); + + @override + State createState() => _AddChannelSheetState(); +} + +class _AddChannelSheetState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _secretController = TextEditingController(); + bool _isCreating = false; + bool _obscureSecret = true; + + @override + void dispose() { + _nameController.dispose(); + _secretController.dispose(); + super.dispose(); + } + + /// Validate that a string contains only ASCII characters + bool _isAscii(String text) { + return text.codeUnits.every((unit) => unit < 128); + } + + /// Validate channel name + String? _validateName(String? value) { + final l10n = AppLocalizations.of(context)!; + final trimmedValue = value?.trim() ?? ''; + + if (trimmedValue.isEmpty) { + return l10n.channelNameRequired; + } + + if (trimmedValue.length > 31) { + return l10n.channelNameTooLong; + } + + if (!_isAscii(trimmedValue)) { + return l10n.invalidAsciiCharacters; + } + + return null; + } + + /// Validate channel secret + String? _validateSecret(String? value) { + final l10n = AppLocalizations.of(context)!; + + if (value == null || value.isEmpty) { + return l10n.channelSecretRequired; + } + + if (value.length > 32) { + return l10n.channelSecretTooLong; + } + + if (!_isAscii(value)) { + return l10n.invalidAsciiCharacters; + } + + return null; + } + + /// Handle channel creation + Future _handleCreate() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isCreating = true; + }); + + try { + final channelName = _nameController.text.trim(); + final isHashChannel = channelName.startsWith('#'); + + // For hash channels, pass empty secret (will be auto-generated) + // For private channels, use the provided secret + final secret = isHashChannel ? '' : _secretController.text; + + await widget.onCreateChannel(channelName, secret); + + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + // Error is handled by parent + setState(() { + _isCreating = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final normalizedName = _nameController.text.trimLeft(); + final isHashChannel = normalizedName.startsWith('#'); + + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.addChannel, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + onPressed: _isCreating + ? null + : () => Navigator.of(context).pop(), + icon: Icon(Icons.close), + tooltip: l10n.close, + ), + ], + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 20, + color: theme.colorScheme.primary, + ), + SizedBox(width: 8), + Expanded( + child: Text( + l10n.channelTypesInfo, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + SizedBox(height: 16), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.channelName, + hintText: l10n.channelNameHint, + border: const OutlineInputBorder(), + prefixIcon: Icon( + isHashChannel ? Icons.tag : Icons.lock_outline, + color: isHashChannel ? Colors.blue : Colors.orange, + ), + ), + enabled: !_isCreating, + maxLength: 31, + validator: _validateName, + textInputAction: isHashChannel + ? TextInputAction.done + : TextInputAction.next, + onChanged: (_) => setState(() {}), + onFieldSubmitted: (_) { + if (isHashChannel) { + _handleCreate(); + } + }, + ), + if (!isHashChannel) ...[ + SizedBox(height: 16), + TextFormField( + controller: _secretController, + decoration: InputDecoration( + labelText: l10n.channelSecret, + hintText: l10n.channelSecretHint, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + onPressed: _isCreating + ? null + : () => setState(() { + _obscureSecret = !_obscureSecret; + }), + icon: Icon( + _obscureSecret + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + tooltip: _obscureSecret + ? 'Show secret' + : 'Hide secret', + ), + ), + obscureText: _obscureSecret, + enabled: !_isCreating, + maxLength: 32, + validator: _validateSecret, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _handleCreate(), + ), + SizedBox(height: 8), + Text( + l10n.channelSecretHelp, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + if (isHashChannel) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon( + Icons.auto_awesome, + size: 20, + color: Colors.blue, + ), + SizedBox(width: 8), + Expanded( + child: Text( + l10n.hashChannelInfo, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isCreating + ? null + : () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: _isCreating ? null : _handleCreate, + child: _isCreating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(l10n.createChannel), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart new file mode 100644 index 0000000..ae7ed5e --- /dev/null +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -0,0 +1,842 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../../models/contact.dart'; +import '../../l10n/app_localizations.dart'; +import '../../providers/app_provider.dart'; +import '../../providers/connection_provider.dart'; +import '../../services/relay_candidate_sorter.dart'; +import '../../services/route_hash_preferences.dart'; + +class ContactRouteDialogResult { + final ParsedContactRoute? route; + final bool shouldClear; + final LatLng? inferredFallbackLocation; + + const ContactRouteDialogResult._({ + this.route, + required this.shouldClear, + this.inferredFallbackLocation, + }); + + const ContactRouteDialogResult.set(ParsedContactRoute route) + : this._(route: route, shouldClear: false); + + const ContactRouteDialogResult.setWithFallback( + ParsedContactRoute route, { + LatLng? inferredFallbackLocation, + }) : this._( + route: route, + shouldClear: false, + inferredFallbackLocation: inferredFallbackLocation, + ); + + const ContactRouteDialogResult.clear() : this._(shouldClear: true); +} + +class ContactRouteDialog extends StatefulWidget { + final Contact contact; + final List availableContacts; + + const ContactRouteDialog({ + super.key, + required this.contact, + required this.availableContacts, + }); + + static Future show( + BuildContext context, { + required Contact contact, + required List availableContacts, + }) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ContactRouteDialog( + contact: contact, + availableContacts: availableContacts, + ), + ), + ); + } + + @override + State createState() => _ContactRouteDialogState(); +} + +class _ContactRouteDialogState extends State { + late final TextEditingController _controller; + late final TextEditingController _relaySearchController; + final RelayCandidateSorter _relayCandidateSorter = + const RelayCandidateSorter(); + int _selectedHashSize = RouteHashPreferences.defaultHashSize; + ParsedContactRoute? _parsedRoute; + String? _errorText; + bool _showRoutingInfo = false; + List _selectedMapHops = const []; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: widget.contact.routeCanonicalText, + ); + _relaySearchController = TextEditingController(); + _controller.addListener(_reparse); + _loadHashSizePreference(); + _reparse(); + } + + @override + void dispose() { + _relaySearchController.dispose(); + _controller + ..removeListener(_reparse) + ..dispose(); + super.dispose(); + } + + void _reparse() { + final input = _controller.text.trim(); + if (input.isEmpty) { + setState(() { + _parsedRoute = null; + _errorText = null; + _selectedMapHops = const []; + }); + return; + } + + try { + final parsed = ContactRouteCodec.parse( + input, + expectedHashSize: _selectedHashSize, + ); + final selectedMapHops = _mapSelectionForText(input); + setState(() { + _parsedRoute = parsed; + _errorText = null; + _selectedMapHops = selectedMapHops; + }); + } on ContactRouteFormatException catch (error) { + setState(() { + _parsedRoute = null; + _errorText = error.message; + }); + } + } + + List _routeCandidates({required LatLng? selfPoint}) => + _relayCandidateSorter.sortByDistanceFromSelf( + widget.availableContacts + .where( + (contact) => + contact.isRepeater && contact.displayLocation != null, + ) + .toList(), + selfPoint: selfPoint, + ); + + List _mapSelectionForText(String text) { + final tokens = text + .trim() + .split(',') + .map((token) => token.trim().toUpperCase()) + .where((token) => token.isNotEmpty) + .toList(); + final selected = []; + final seen = {}; + for (final token in tokens) { + final match = _routeCandidates(selfPoint: null) + .where( + (contact) => contact.publicKeyHex.toUpperCase().startsWith(token), + ) + .firstOrNull; + if (match != null && seen.add(match.publicKeyHex)) { + selected.add(match); + } + } + return selected; + } + + Future _loadHashSizePreference() async { + // Use the contact's current path hash size if it has a path, + // otherwise fall back to the global preference. + final contactHashSize = widget.contact.hasPath + ? widget.contact.pathHashSize + : null; + final hashSize = + contactHashSize ?? await RouteHashPreferences.getHashSize(); + if (!mounted) return; + setState(() { + _selectedHashSize = hashSize; + }); + _reparse(); + } + + String _tokenFor(Contact contact, int hashSize) { + final hex = contact.publicKeyHex.toUpperCase(); + final length = hashSize * 2; + if (hex.length < length) { + return hex; + } + return hex.substring(0, length); + } + + void _syncControllerFromSelectedHops() { + final tokens = _selectedMapHops + .map((contact) => _tokenFor(contact, _selectedHashSize)) + .toList(); + _controller.text = tokens.join(','); + _controller.selection = TextSelection.fromPosition( + TextPosition(offset: _controller.text.length), + ); + } + + ParsedContactRoute? get _effectiveRoute { + if (_parsedRoute != null) { + return _parsedRoute; + } + if (_errorText != null || _controller.text.trim().isNotEmpty) { + return null; + } + return ContactRouteCodec.direct(hashSize: _selectedHashSize); + } + + void _toggleHop(Contact contact) { + setState(() { + if (_selectedMapHops.any( + (item) => item.publicKeyHex == contact.publicKeyHex, + )) { + _selectedMapHops = _selectedMapHops + .where((item) => item.publicKeyHex != contact.publicKeyHex) + .toList(); + } else { + _selectedMapHops = [..._selectedMapHops, contact]; + } + _syncControllerFromSelectedHops(); + _reparse(); + }); + } + + LatLng? _resolveLastHopLocation() { + if (_selectedMapHops.isNotEmpty) { + return _selectedMapHops.last.displayLocation == null + ? null + : LatLng( + _selectedMapHops.last.displayLocation!.latitude, + _selectedMapHops.last.displayLocation!.longitude, + ); + } + + final tokens = _controller.text + .trim() + .split(',') + .map((token) => token.trim().toUpperCase()) + .where((token) => token.isNotEmpty) + .toList(); + if (tokens.isEmpty) return null; + final lastToken = tokens.last; + final match = _routeCandidates(selfPoint: null) + .where( + (contact) => contact.publicKeyHex.toUpperCase().startsWith(lastToken), + ) + .firstOrNull; + final location = match?.displayLocation; + if (location == null) return null; + return LatLng(location.latitude, location.longitude); + } + + LatLng? _buildSyntheticFallbackLocation() { + final lastHopLocation = _resolveLastHopLocation(); + if (lastHopLocation == null) return null; + + final seed = widget.contact.publicKey.fold( + _controller.text.codeUnits.fold(0, (sum, unit) => sum + unit), + (sum, byte) => sum + byte, + ); + final angle = (seed % 360) * (math.pi / 180.0); + const radiusMeters = 500.0; + final latOffset = (radiusMeters / 111320.0) * math.cos(angle); + final lonDenominator = + 111320.0 * math.cos(lastHopLocation.latitude * (math.pi / 180.0)); + final lonOffset = lonDenominator.abs() < 1e-6 + ? 0.0 + : (radiusMeters / lonDenominator) * math.sin(angle); + return LatLng( + lastHopLocation.latitude + latOffset, + lastHopLocation.longitude + lonOffset, + ); + } + + Widget _buildPreviewSection() { + final previewRoute = _effectiveRoute; + if (previewRoute == null) { + return const SizedBox.shrink(); + } + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + previewRoute.summary, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 2), + Text( + '${previewRoute.byteLength}B • 0x${previewRoute.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (previewRoute.canonicalText.isNotEmpty) ...[ + const SizedBox(height: 8), + SelectableText( + previewRoute.canonicalText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ), + ], + ], + ), + ); + } + + Widget _buildSelectedHopSection() { + if (_selectedMapHops.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AppLocalizations.of(context)!.selectedRelays, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedMapHops.asMap().entries.map((entry) { + final index = entry.key; + final contact = entry.value; + return InputChip( + label: Text('${index + 1}. ${contact.displayName}'), + deleteIcon: const Icon(Icons.close), + onDeleted: () => _toggleHop(contact), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + }).toList(), + ), + ], + ); + } + + Widget _buildRelayPicker(List routeCandidates) { + final query = _relaySearchController.text.trim().toLowerCase(); + final filteredCandidates = routeCandidates.where((contact) { + if (query.isEmpty) return true; + return contact.displayName.toLowerCase().contains(query) || + _tokenFor(contact, _selectedHashSize).toLowerCase().contains(query); + }).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _relaySearchController, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + labelText: 'Find relay', + hintText: AppLocalizations.of(context)!.searchByNameOrToken, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + if (filteredCandidates.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Theme.of(context).dividerColor), + ), + child: Text( + 'No visible relays match this search.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + else + ...filteredCandidates.map((candidate) { + final isSelected = _selectedMapHops.any( + (item) => item.publicKeyHex == candidate.publicKeyHex, + ); + final location = candidate.displayLocation; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + leading: Icon( + isSelected + ? Icons.check_circle + : Icons.radio_button_unchecked, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + title: Text(candidate.displayName), + subtitle: Text( + [ + _tokenFor(candidate, _selectedHashSize), + if (location != null) + '${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}', + ].join(' • '), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + ), + trailing: TextButton( + onPressed: () => _toggleHop(candidate), + child: Text(isSelected ? 'Remove' : 'Add'), + ), + ), + ); + }), + ], + ); + } + + Widget _buildMapPreview({ + required List routeCandidates, + required List mapPoints, + required List routePoints, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.outlineVariant), + ), + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: 220, + child: mapPoints.length < 2 + ? const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Map preview needs your advertised location, the contact location, and visible repeater locations.', + textAlign: TextAlign.center, + ), + ), + ) + : flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCameraFit: flutter_map.CameraFit.bounds( + bounds: flutter_map.LatLngBounds.fromPoints(mapPoints), + padding: const EdgeInsets.all(32), + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.meshcore.sar', + ), + if (routePoints.length >= 2) + flutter_map.PolylineLayer( + polylines: [ + flutter_map.Polyline( + points: routePoints, + strokeWidth: 4, + color: colorScheme.primary, + ), + ], + ), + flutter_map.MarkerLayer( + markers: [ + ...routeCandidates.map((candidate) { + final isSelected = _selectedMapHops.any( + (item) => item.publicKeyHex == candidate.publicKeyHex, + ); + return flutter_map.Marker( + point: LatLng( + candidate.displayLocation!.latitude, + candidate.displayLocation!.longitude, + ), + width: 64, + height: 70, + child: GestureDetector( + onTap: () => _toggleHop(candidate), + child: _RouteMarkerDot( + label: _tokenFor(candidate, _selectedHashSize), + color: isSelected + ? colorScheme.primary + : Colors.blueGrey, + ), + ), + ); + }), + ], + ), + ], + ), + ), + ); + } + + Widget _buildBuilderTab({ + required List routeCandidates, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(AppLocalizations.of(context)!.pathSize, style: Theme.of(context).textTheme.labelLarge), + const Spacer(), + SegmentedButton( + segments: [ + for (final size in RouteHashPreferences.supportedSizes) + ButtonSegment(value: size, label: Text('${size}B')), + ], + selected: {_selectedHashSize}, + onSelectionChanged: (selection) { + setState(() { + _selectedHashSize = selection.first; + _syncControllerFromSelectedHops(); + }); + }, + ), + ], + ), + if (_errorText != null) ...[ + const SizedBox(height: 12), + Text( + _errorText!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + if (_selectedMapHops.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildSelectedHopSection(), + ], + const SizedBox(height: 12), + _buildRelayPicker(routeCandidates), + ], + ); + } + + Widget _buildInfoTab({ + required AppProvider appProvider, + required List routeCandidates, + required List mapPoints, + required List routePoints, + }) { + return ListView( + children: [ + _buildPreviewSection(), + const SizedBox(height: 16), + _buildMapPreview( + routeCandidates: routeCandidates, + mapPoints: mapPoints, + routePoints: routePoints, + ), + const SizedBox(height: 16), + _AutomationRoutingInfo( + isExpanded: _showRoutingInfo, + onToggle: () { + setState(() { + _showRoutingInfo = !_showRoutingInfo; + }); + }, + nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled, + clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, + ), + const SizedBox(height: 24), + ], + ); + } + + @override + Widget build(BuildContext context) { + final effectiveRoute = _effectiveRoute; + final appProvider = context.watch(); + final connectionProvider = context.watch(); + final selfPoint = + connectionProvider.deviceInfo.advLat != null && + connectionProvider.deviceInfo.advLon != null && + !(connectionProvider.deviceInfo.advLat == 0 && + connectionProvider.deviceInfo.advLon == 0) + ? LatLng( + connectionProvider.deviceInfo.advLat! / 1e6, + connectionProvider.deviceInfo.advLon! / 1e6, + ) + : null; + final routeCandidates = _routeCandidates(selfPoint: selfPoint); + final recipientLocation = widget.contact.displayLocation; + final recipientPoint = recipientLocation == null + ? null + : LatLng(recipientLocation.latitude, recipientLocation.longitude); + final routePoints = [ + ...?selfPoint == null ? null : [selfPoint], + ..._selectedMapHops + .where((contact) => contact.displayLocation != null) + .map( + (contact) => LatLng( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), + ), + ...?recipientPoint == null ? null : [recipientPoint], + ]; + final mapPoints = [ + ...?selfPoint == null ? null : [selfPoint], + ...?recipientPoint == null ? null : [recipientPoint], + ...routeCandidates.map( + (contact) => LatLng( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), + ), + ]; + + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.setPathForContact(widget.contact.displayName)), + bottom: const TabBar( + tabs: [ + Tab(text: 'Build'), + Tab(text: 'Info'), + ], + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: TabBarView( + children: [ + ListView( + children: [ + _buildBuilderTab( + routeCandidates: routeCandidates, + ), + const SizedBox(height: 24), + ], + ), + _buildInfoTab( + appProvider: appProvider, + routeCandidates: routeCandidates, + mapPoints: mapPoints, + routePoints: routePoints, + ), + ], + ), + ), + ), + bottomNavigationBar: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: OverflowBar( + alignment: MainAxisAlignment.spaceBetween, + spacing: 8, + overflowSpacing: 8, + children: [ + if (widget.contact.routeHasPath) + TextButton( + onPressed: () => Navigator.of( + context, + ).pop(ContactRouteDialogResult.clear()), + child: Text( + '${AppLocalizations.of(context)!.clearRoute} (${AppLocalizations.of(context)!.flood})', + ), + ) + else + const SizedBox.shrink(), + FilledButton( + onPressed: effectiveRoute == null + ? null + : () => Navigator.of(context).pop( + ContactRouteDialogResult.setWithFallback( + effectiveRoute, + inferredFallbackLocation: + _buildSyntheticFallbackLocation(), + ), + ), + child: Text(AppLocalizations.of(context)!.savePath), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _RouteMarkerDot extends StatelessWidget { + final String label; + final Color color; + + const _RouteMarkerDot({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: label, + child: Center( + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ); + } +} + +class _AutomationRoutingInfo extends StatelessWidget { + final bool isExpanded; + final VoidCallback onToggle; + final bool nearestRelayFallbackEnabled; + final bool clearPathOnMaxRetry; + + const _AutomationRoutingInfo({ + required this.isExpanded, + required this.onToggle, + required this.nearestRelayFallbackEnabled, + required this.clearPathOnMaxRetry, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onToggle, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Automatic direct-send routing', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + color: colorScheme.primary, + ), + ], + ), + ), + ), + if (isExpanded) ...[ + const SizedBox(height: 8), + Text( + 'Room/contact sends use the current direct path when one is known, switch to flood on the last normal retry, then try one final nearest repeater if everything else fails.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + Text( + 'Public and channel broadcasts are not affected by this automation.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InfoChip( + label: nearestRelayFallbackEnabled + ? 'Nearest repeater fallback on' + : 'Nearest repeater fallback off', + icon: Icons.router, + ), + _InfoChip( + label: clearPathOnMaxRetry + ? 'Clear path on max retry on' + : 'Clear path on max retry off', + icon: Icons.route, + ), + ], + ), + ] else ...[ + const SizedBox(height: 6), + Text( + 'Shows retry and final repeater fallback behavior.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ); + } +} + +class _InfoChip extends StatelessWidget { + final String label; + final IconData icon; + + const _InfoChip({required this.label, required this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: Theme.of(context).colorScheme.surface, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14), + const SizedBox(width: 6), + Text(label, style: Theme.of(context).textTheme.labelMedium), + ], + ), + ); + } +} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart new file mode 100644 index 0000000..fb69a9e --- /dev/null +++ b/lib/widgets/contacts/contact_tile.dart @@ -0,0 +1,2825 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import '../../models/contact.dart'; +import '../../models/room_login_state.dart'; +import '../../providers/app_provider.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; +import '../../providers/map_provider.dart'; +import '../../providers/messages_provider.dart'; +import '../../providers/sensors_provider.dart'; +import '../../services/location_tracking_service.dart'; +import '../../services/message_destination_preferences.dart'; +import '../../services/path_history_service.dart'; +import 'contact_route_dialog.dart'; +import 'ping_contact_sheet.dart'; +import 'room_login_sheet.dart'; +import '../common/contact_avatar.dart'; +import '../sensors/bthome_met_history_sheet.dart'; +import '../sensors/sensor_telemetry_card.dart'; +import '../../utils/link_quality.dart'; +import '../../utils/time_ago_extensions.dart'; +import '../../utils/toast_logger.dart'; +import '../../l10n/app_localizations.dart'; + +class ContactTile extends StatelessWidget { + final Contact contact; + final String? groupLabel; + final bool compact; + final Position? currentPosition; + final double Function(double, double, double, double)? calculateDistance; + final String Function(double)? formatDistance; + final VoidCallback? onNavigateToMap; + final VoidCallback? onNavigateToMessages; + + const ContactTile({ + super.key, + required this.contact, + this.groupLabel, + this.compact = false, + this.currentPosition, + this.calculateDistance, + this.formatDistance, + this.onNavigateToMap, + this.onNavigateToMessages, + }); + + String _getLocalizedRelativeTime(BuildContext context, DateTime when) { + final l10n = AppLocalizations.of(context)!; + final diff = DateTime.now().difference(when); + + if (diff.inMinutes < 1) return l10n.justNow; + if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); + if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours); + return l10n.daysAgo(diff.inDays); + } + + String _threeBytePrefix() { + final bytes = contact.publicKey.take(3); + return bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + } + + Widget _buildChannelLocationSharingMarker( + BuildContext context, + ChannelLocationSharingMode mode, + ) { + const accent = Color(0xFF16A34A); + + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: Icon( + Icons.location_pin, + size: 11, + color: Colors.white, + ), + ); + } + + @override + Widget build(BuildContext context) { + final isChannel = contact.type == ContactType.channel; + final isRoom = contact.type == ContactType.room; + final isRoomOrChannel = isRoom || isChannel; + final location = contact.displayLocation; + // Calculate distance if both positions are available + String? distanceText; + if (location != null && + currentPosition != null && + calculateDistance != null && + formatDistance != null) { + final distanceMeters = calculateDistance!( + currentPosition!.latitude, + currentPosition!.longitude, + location.latitude, + location.longitude, + ); + distanceText = formatDistance!(distanceMeters); + } + + // Get room login state if this is a room + final connectionProvider = context.watch(); + final messagesProvider = context.watch(); + final appProvider = context.watch(); + final isPingInProgress = connectionProvider.isPingInProgress( + contact.publicKey, + ); + final roomLoginState = contact.type == ContactType.room + ? connectionProvider.getRoomLoginState(contact.publicKeyPrefix) + : null; + void handleTap() => _handlePrimaryTap(context, contact); + + final colorScheme = Theme.of(context).colorScheme; + final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + ); + final lastActivityAt = isRoomOrChannel + ? messagesProvider.getLastActivityForDestination(contact) + : null; + final channelLocationSharingMode = + isChannel && !contact.isPublicChannel && contact.publicKey.length > 1 + ? appProvider.channelLocationSharingModeForChannel(contact.publicKey[1]) + : null; + final timeAgoText = _getLocalizedRelativeTime( + context, + lastActivityAt ?? contact.lastSeenTime, + ); + final timeAgoStyle = Theme.of(context).textTheme.labelSmall?.copyWith( + color: contact.isRecentlySeen + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ); + final Widget subtitleWidget = compact + ? _buildCompactSubtitle(context, distanceText) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + if (groupLabel case final label?) + _buildMetaPill( + context, + icon: Icons.folder_copy_outlined, + label: label, + ), + ..._buildSignalPills(context), + ], + ), + if (location != null) ...[ + const SizedBox(height: 2), + _buildLocationLine( + context, + latitude: location.latitude, + longitude: location.longitude, + distanceText: distanceText, + ), + const SizedBox(height: 6), + Row(children: [_buildRoutePill(context, contact)]), + ] else + Padding( + padding: EdgeInsets.only(top: 4), + child: Text( + AppLocalizations.of(context)!.noGpsData, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.grey), + ), + ), + ], + ); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: colorScheme.surfaceContainerLow, + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.045), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: handleTap, + onLongPress: handleTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 58, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + ContactAvatar(contact: contact, radius: 24), + if (contact.isNew) + Positioned( + top: -2, + right: -2, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + ), + ), + ), + if (isChannel && channelLocationSharingMode != null) + Positioned( + bottom: -2, + right: -2, + child: _buildChannelLocationSharingMarker( + context, + channelLocationSharingMode, + ), + ), + if (contact.type == ContactType.room && + roomLoginState != null) + Positioned( + bottom: -2, + right: -2, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: _getRoomStatusColor(roomLoginState), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + ), + child: Icon( + _getRoomStatusIcon(roomLoginState), + size: 11, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + _threeBytePrefix(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + contact.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + ], + ), + ), + const SizedBox(width: 8), + if (!isRoomOrChannel || lastActivityAt != null) + Text(timeAgoText, style: timeAgoStyle), + if (isPingInProgress) ...[ + const SizedBox(width: 6), + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + ], + ], + ), + subtitleWidget, + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + List _buildSignalPills(BuildContext context) { + if (!contact.isRepeater && !contact.isSensor) return []; + final contactsProvider = context.read(); + final advert = contactsProvider.pendingAdvertByKey(contact.publicKey); + if (advert == null) return []; + + final pills = []; + if (advert.rxRssiDbm != null) { + pills.add( + _buildMetaPill( + context, + icon: Icons.arrow_downward_rounded, + label: '${advert.rxRssiDbm} dBm', + ), + ); + } + if (advert.repeaterLastRssi != null) { + pills.add( + _buildMetaPill( + context, + icon: Icons.arrow_upward_rounded, + label: '${advert.repeaterLastRssi} dBm', + ), + ); + } + return pills; + } + + Widget _buildCompactSubtitle(BuildContext context, String? distanceText) { + final location = contact.displayLocation; + final compactPills = [ + if (groupLabel case final label?) + _buildMetaPill(context, icon: Icons.folder_copy_outlined, label: label), + if (distanceText != null) _buildDistancePill(context, distanceText), + if (contact.routeHasPath && contact.routeHopCount > 0) + _buildRoutePill(context, contact), + if (location == null) + _buildMetaPill( + context, + icon: Icons.location_disabled_outlined, + label: AppLocalizations.of(context)!.noGpsData, + ), + ..._buildSignalPills(context), + ]; + + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap(spacing: 6, runSpacing: 6, children: compactPills), + ); + } + + void _handlePrimaryTap(BuildContext context, Contact contact) { + _showContactActionSheet(context, contact); + } + + Future _smartPingContact(BuildContext context, Contact contact) async { + final connectionProvider = context.read(); + final hasPath = contact.routeHasPath; + + final result = await connectionProvider.smartPing( + contactPublicKey: contact.publicKey, + hasPath: hasPath, + onRetryWithFlooding: () { + if (context.mounted) { + ToastLogger.warning( + context, + AppLocalizations.of( + context, + )!.directPingTimeout(contact.displayName), + ); + } + }, + ); + + if (!context.mounted) return; + if (result.success) { + ToastLogger.success(context, 'Ping reply from ${contact.displayName}'); + } else { + ToastLogger.error( + context, + AppLocalizations.of(context)!.pingFailed(contact.displayName), + ); + } + } + + String _locationSharingErrorMessage(Object error) { + final message = error.toString(); + if (message.startsWith('Bad state: ')) { + return message.substring('Bad state: '.length); + } + return message; + } + + Future _setChannelLocationSharing( + BuildContext context, + Contact contact, + bool enabled, + ) async { + final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0; + try { + final result = await context.read().setChannelLocationSharingEnabled( + channelIdx, + enabled, + l10n: AppLocalizations.of(context), + ); + if (!context.mounted) return; + ToastLogger.success(context, result.message); + } catch (error) { + if (!context.mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + + Future _handleChannelLocationSharingAction( + BuildContext context, + Contact contact, + ) async { + if (contact.isPublicChannel) { + if (!context.mounted) return; + ToastLogger.error( + context, + 'Select a private channel to share your location.', + ); + return; + } + + final channelIdx = contact.publicKey.length > 1 ? contact.publicKey[1] : 0; + if (channelIdx <= 0) { + if (!context.mounted) return; + ToastLogger.error( + context, + 'Select a private channel to share your location.', + ); + return; + } + + try { + final sharingState = await context + .read() + .getChannelLocationSharingState(channelIdx); + if (!context.mounted) return; + await _setChannelLocationSharing(context, contact, !sharingState.isSharing); + } catch (error) { + if (!context.mounted) return; + ToastLogger.error(context, _locationSharingErrorMessage(error)); + } + } + + void _showContactActionSheet(BuildContext context, Contact contact) { + final l10n = AppLocalizations.of(context)!; + final canToggleFavourite = !contact.isChannel; + final canMessage = + contact.type == ContactType.chat || + contact.type == ContactType.room || + contact.type == ContactType.channel; + final canSetPath = + contact.type == ContactType.chat || + contact.type == ContactType.room || + contact.type == ContactType.repeater || + contact.type == ContactType.sensor; + final canAddToSensors = + contact.type == ContactType.chat || + contact.type == ContactType.repeater || + contact.type == ContactType.sensor; + final canPreviewSensor = contact.isSensor; + final canSmartPing = + contact.type == ContactType.chat || contact.type == ContactType.sensor; + final isPingInProgress = context.read().isPingInProgress( + contact.publicKey, + ); + final sensorsProvider = context.read(); + final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); + final channelLocationSharingFuture = + contact.type == ContactType.channel && + !contact.isPublicChannel && + contact.publicKey.length > 1 + ? context + .read() + .getChannelLocationSharingState(contact.publicKey[1]) + : null; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + Widget buildSheet(ChannelLocationSharingState? sharingState) { + final primaryActions = <_ContactSheetAction>[ + if (canMessage) + _ContactSheetAction( + icon: Icons.message_outlined, + label: l10n.messages, + onTap: () async { + Navigator.pop(context); + await _openMessagesForContact(context, contact); + }, + ), + if (contact.type == ContactType.channel) + _ContactSheetAction( + icon: sharingState?.isSharing == true + ? Icons.location_off_rounded + : Icons.share_location_rounded, + label: sharingState?.isSharing == true + ? 'Stop sharing my location' + : 'Share my location', + onTap: () async { + Navigator.pop(context); + await _handleChannelLocationSharingAction(context, contact); + }, + ), + if (!contact.isChannel) + _ContactSheetAction( + icon: Icons.share_outlined, + label: l10n.share, + onTap: () async { + Navigator.pop(context); + final connectionProvider = context.read(); + final url = await connectionProvider.exportContactUrl( + contact.publicKey, + ); + if (url != null && context.mounted) { + await Clipboard.setData(ClipboardData(text: url)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.contactLinkCopiedToClipboard), + ), + ); + } + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.failedToExportContact)), + ); + } + }, + ), + if (canSetPath) + _ContactSheetAction( + icon: Icons.alt_route, + label: l10n.contactSetPath, + onTap: () async { + Navigator.pop(context); + await _showSetRouteDialog(context, contact); + }, + ), + if (!contact.isPublicChannel) + _ContactSheetAction( + icon: Icons.delete_outline_rounded, + label: l10n.delete, + destructive: true, + onTap: () async { + Navigator.pop(context); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + if (contact.isChannel) { + _showDeleteChannelDialog(context, contact); + } else { + _showDeleteConfirmation(context, contact); + } + }); + }, + ), + ]; + + final secondaryActions = <_ContactSheetAction>[ + if (contact.displayLocation != null) + _ContactSheetAction( + icon: Icons.map_outlined, + label: l10n.viewOnMap, + onTap: () async { + Navigator.pop(context); + _showContactOnMap(context, contact); + }, + ), + if (contact.type == ContactType.room && !contact.isPublicChannel) + _ContactSheetAction( + icon: Icons.login, + label: + context + .read() + .getRoomLoginState(contact.publicKeyPrefix) + ?.isLoggedIn == + true + ? l10n.reLoginToRoom + : l10n.loginToRoom, + onTap: () async { + Navigator.pop(context); + _showRoomLoginDialog(context, contact); + }, + ), + if (canPreviewSensor) + _ContactSheetAction( + icon: Icons.visibility_outlined, + label: l10n.preview, + onTap: () async { + Navigator.pop(context); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + await _showSensorPreviewView(context, contact); + }, + ), + if (canAddToSensors) + _ContactSheetAction( + icon: isInSensors ? Icons.sensors : Icons.sensors_outlined, + label: isInSensors + ? l10n.contactInSensors + : l10n.contactAddToSensors, + enabled: !isInSensors, + onTap: () async { + Navigator.pop(context); + await _addContactToSensors(context, contact); + }, + ), + if (contact.type == ContactType.repeater) + _ContactSheetAction( + icon: Icons.hub_outlined, + label: AppLocalizations.of(context)!.viewNeighbours, + onTap: () async { + Navigator.pop(context); + _showNeighbours(context, contact); + }, + ), + if (contact.type == ContactType.repeater || + contact.type == ContactType.room) + _ContactSheetAction( + icon: Icons.network_ping, + label: AppLocalizations.of(context)!.ping, + onTap: () async { + Navigator.pop(context); + _pingRelay(context, contact); + }, + ), + if (canSmartPing) + _ContactSheetAction( + icon: Icons.network_ping, + label: l10n.ping, + enabled: !isPingInProgress, + onTap: () async { + Navigator.pop(context); + await _smartPingContact(context, contact); + }, + ), + if (!contact.isPublicChannel) + _ContactSheetAction( + icon: Icons.edit_outlined, + label: l10n.editName, + onTap: () async { + Navigator.pop(context); + _showNameOverrideDialog(context, contact); + }, + ), + ]; + + return _ContactActionSheet( + contact: contact, + primaryActions: primaryActions, + secondaryActions: secondaryActions, + showFavouriteButton: canToggleFavourite, + initialFavourite: contact.isFavourite, + onClose: () => Navigator.pop(sheetContext), + onToggleFavourite: !canToggleFavourite + ? null + : () async { + final toggled = contact.toggleFavourite(); + final connectionProvider = + context.read(); + await connectionProvider.addOrUpdateContact(toggled); + if (context.mounted) { + await connectionProvider.getContact(contact.publicKey); + } + }, + ); + } + + if (channelLocationSharingFuture == null) { + return buildSheet(null); + } + + return FutureBuilder( + future: channelLocationSharingFuture, + builder: (context, snapshot) { + return buildSheet(snapshot.data); + }, + ); + }, + ); + } + + Future _showSensorPreviewView( + BuildContext context, + Contact contact, + ) async { + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (pageContext) => _SensorPreviewView(contact: contact), + ), + ); + } + + void _showContactOnMap(BuildContext context, Contact contact) { + final location = contact.displayLocation; + if (location == null) { + return; + } + + context.read().navigateToLocation( + location: LatLng(location.latitude, location.longitude), + ); + onNavigateToMap?.call(); + } + + Future _addContactToSensors( + BuildContext context, + Contact contact, + ) async { + await context.read().addSensor(contact); + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.contactAddedToSensors(contact.displayName), + ), + ), + ); + } + + Future _openMessagesForContact( + BuildContext context, + Contact contact, + ) async { + final messagesProvider = context.read(); + final destinationType = contact.type == ContactType.channel + ? MessageDestinationPreferences.destinationTypeChannel + : contact.type == ContactType.room + ? MessageDestinationPreferences.destinationTypeRoom + : MessageDestinationPreferences.destinationTypeContact; + + await MessageDestinationPreferences.setDestination( + destinationType, + recipientPublicKey: contact.publicKeyHex, + ); + messagesProvider.navigateToDestination( + destinationType, + recipientPublicKeyHex: contact.publicKeyHex, + ); + onNavigateToMessages?.call(); + } + + void _showRoomLoginDialog(BuildContext context, Contact contact) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => RoomLoginSheet(contact: contact), + ); + } + + void _showNeighbours(BuildContext context, Contact contact) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => _NeighboursSheet(contact: contact), + ); + } + + void _pingRelay(BuildContext context, Contact contact) { + showModalBottomSheet( + 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), + ); + } + + void _showDeleteConfirmation( + BuildContext context, + Contact contact, { + bool closeDetailsSheetOnDelete = false, + }) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.deleteContact), + content: Text( + AppLocalizations.of( + context, + )!.deleteContactConfirmation(contact.displayName), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); // Close confirmation dialog + if (closeDetailsSheetOnDelete) { + Navigator.pop(context); // Close contact details sheet + } + await _deleteContact(context, contact); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.delete), + ), + ], + ), + ); + } + + Future _deleteContact(BuildContext context, Contact contact) async { + final connectionProvider = context.read(); + final contactsProvider = context.read(); + + try { + // Remove contact from provider (which will also remove from device) + await contactsProvider.removeContact( + contact.publicKeyHex, + onRemoveFromDevice: (publicKey) async { + if (connectionProvider.deviceInfo.isConnected) { + await connectionProvider.removeContact(publicKey); + } + }, + ); + } catch (e) { + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToRemoveContact(e.toString()), + ); + } + } + } + + Future _showNameOverrideDialog( + BuildContext context, + Contact contact, + ) async { + final l10n = AppLocalizations.of(context)!; + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (dialogContext) => SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(dialogContext).viewInsets.bottom, + ), + child: _ContactNameOverrideSheet( + initialValue: contact.nameOverride ?? '', + advertisedName: contact.advName, + cancelLabel: l10n.cancel, + saveLabel: l10n.save, + ), + ), + ), + ); + + if (result == null || !context.mounted) { + return; + } + + context.read().setContactNameOverride( + contact.publicKeyHex, + result, + ); + } + + Future _showSetRouteDialog( + BuildContext context, + Contact contact, + ) async { + final contactsProvider = context.read(); + final connectionProvider = context.read(); + final pathHistoryService = PathHistoryService(); + final availableContacts = contactsProvider.contacts + .where((candidate) => candidate.publicKeyHex != contact.publicKeyHex) + .toList(); + + final routeResult = await ContactRouteDialog.show( + context, + contact: contact, + availableContacts: availableContacts, + ); + if (routeResult == null || !context.mounted) { + return; + } + + final previousSignedPathLen = contact.routeSignedPathLen; + final previousPathBytes = Uint8List.fromList(contact.outPath); + if (routeResult.shouldClear) { + contactsProvider.resetContactRouteLocal(contact.publicKey); + await connectionProvider.resetPath(contact.publicKey); + final error = connectionProvider.error; + if (error != null) { + contactsProvider.setContactRouteLocal( + contact.publicKey, + signedEncodedPathLen: previousSignedPathLen, + paddedPathBytes: previousPathBytes, + ); + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of( + context, + )!.contactFailedToClearRoute(error.toString()), + ); + } + return; + } + + await pathHistoryService.clearManualRouteFor(contact.publicKeyHex); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.contactRouteCleared), + ), + ); + } + return; + } + + final parsedRoute = routeResult.route!; + contactsProvider.setContactRouteLocal( + contact.publicKey, + signedEncodedPathLen: parsedRoute.signedEncodedPathLen, + paddedPathBytes: parsedRoute.paddedPathBytes, + inferredFallbackLocation: routeResult.inferredFallbackLocation, + ); + + try { + await connectionProvider.setContactRoute( + contact, + signedEncodedPathLen: parsedRoute.signedEncodedPathLen, + paddedPathBytes: parsedRoute.paddedPathBytes, + ); + await pathHistoryService.setManualRouteForContact(contact, parsedRoute); + if (context.mounted) { + final routeLabel = parsedRoute.hopCount == 0 + ? AppLocalizations.of(context)!.direct + : parsedRoute.canonicalText; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.contactRouteSet(routeLabel), + ), + ), + ); + } + } catch (error) { + contactsProvider.setContactRouteLocal( + contact.publicKey, + signedEncodedPathLen: previousSignedPathLen, + paddedPathBytes: previousPathBytes, + ); + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of( + context, + )!.contactFailedToSetRoute(error.toString()), + ); + } + } + } + + Widget _buildLocationMeta( + BuildContext context, + double latitude, + double longitude, { + bool telemetryActive = true, + }) { + final colorScheme = Theme.of(context).colorScheme; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: telemetryActive + ? colorScheme.primary.withValues(alpha: 0.12) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Icon( + telemetryActive + ? Icons.navigation_rounded + : Icons.location_searching, + size: 11, + color: telemetryActive + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 6), + Text( + '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontFamily: 'monospace', + color: colorScheme.onSurfaceVariant, + letterSpacing: 0.1, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ); + } + + Widget _buildMetaPill( + BuildContext context, { + required IconData icon, + required String label, + bool monospace = false, + }) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + fontFamily: monospace ? 'monospace' : null, + ), + ), + ], + ), + ); + } + + Widget _buildLocationLine( + BuildContext context, { + required double latitude, + required double longitude, + String? distanceText, + bool telemetryActive = true, + }) { + return Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _buildLocationMeta( + context, + latitude, + longitude, + telemetryActive: telemetryActive, + ), + if (distanceText != null) _buildDistancePill(context, distanceText), + ], + ); + } + + /// Get room login status color + Color _getRoomStatusColor(RoomLoginState state) { + if (!state.isLoggedIn) { + return Colors.grey; // Grey for not logged in + } + if (state.isAdmin) { + return Colors.red; // Red for admin + } + return Colors.green; // Green for logged in (non-admin) + } + + /// Get room login status icon + IconData _getRoomStatusIcon(RoomLoginState state) { + if (!state.isLoggedIn) { + return Icons.lock; // Lock for not logged in + } + if (state.isAdmin) { + return Icons.admin_panel_settings; // Admin icon for admin + } + return Icons.check; // Check for logged in (non-admin) + } + + /// Show delete channel confirmation dialog + void _showDeleteChannelDialog( + BuildContext context, + Contact contact, { + bool closeDetailsSheetOnDelete = false, + }) { + if (contact.isPublicChannel) { + return; + } + + final l10n = AppLocalizations.of(context)!; + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.deleteChannel), + content: Text(l10n.deleteChannelConfirmation(contact.advName)), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () async { + Navigator.of(dialogContext).pop(); + if (closeDetailsSheetOnDelete) { + Navigator.of(context).pop(); + } + + try { + // Extract channel index from pseudo public key + // publicKey format: [0xFF, channelIdx, ...] + final channelIdx = contact.publicKey[1]; + + final connectionProvider = context.read(); + await connectionProvider.deleteChannel(channelIdx); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelDeletedSuccessfully), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.channelDeletionFailed(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + } + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(l10n.delete), + ), + ], + ), + ); + } + + Widget _buildRoutePill(BuildContext context, Contact contact) { + final hasPath = contact.routeHasPath; + final isDirect = !hasPath || contact.routeHopCount <= 0; + final scheme = Theme.of(context).colorScheme; + final textColor = Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.82); + final iconColor = Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.7); + final label = isDirect + ? AppLocalizations.of(context)!.direct + : contact.routeCanonicalText; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isDirect ? Icons.north_east_rounded : Icons.alt_route, + size: 11, + color: iconColor, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + fontFamily: !isDirect ? 'monospace' : null, + ), + ), + ), + ], + ), + ); + } + + Widget _buildDistancePill(BuildContext context, String distanceText) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.straighten, + size: 11, + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.7), + ), + const SizedBox(width: 5), + Text( + distanceText, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.82), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _SensorPreviewView extends StatelessWidget { + final Contact contact; + + const _SensorPreviewView({required this.contact}); + + @override + Widget build(BuildContext context) { + final publicKeyHex = contact.publicKeyHex; + return Consumer2( + builder: (context, contactsProvider, sensorsProvider, child) { + Contact? liveContact; + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == publicKeyHex) { + liveContact = entry; + break; + } + } + + final previewContact = liveContact ?? contact; + final visibleFields = sensorMetricKeysFor(previewContact); + final fieldOrder = sensorsProvider.metricOrderFor( + publicKeyHex, + visibleFields, + ); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text(previewContact.displayName), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + SensorTelemetryCard( + contact: previewContact, + state: sensorsProvider.stateFor(publicKeyHex), + visibleFields: visibleFields, + fieldOrder: fieldOrder, + labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex), + onShowMetHistory: (contact) => + showBTHomeMetHistorySheet(context, contact: contact), + fieldSpans: sensorFullWidthFieldSpans(visibleFields), + margin: EdgeInsets.zero, + emptyMetricsMessage: 'No telemetry fields available yet.', + ), + ], + ), + ); + }, + ); + } +} + +class _ContactSheetAction { + final IconData icon; + final String label; + final Future Function() onTap; + final bool destructive; + final bool enabled; + + const _ContactSheetAction({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + this.enabled = true, + }); +} + +class _ContactActionSheet extends StatefulWidget { + final Contact contact; + final List<_ContactSheetAction> primaryActions; + final List<_ContactSheetAction> secondaryActions; + final bool showFavouriteButton; + final bool initialFavourite; + final VoidCallback onClose; + final Future Function()? onToggleFavourite; + + const _ContactActionSheet({ + required this.contact, + required this.primaryActions, + required this.secondaryActions, + required this.showFavouriteButton, + required this.initialFavourite, + required this.onClose, + required this.onToggleFavourite, + }); + + @override + State<_ContactActionSheet> createState() => _ContactActionSheetState(); +} + +class _ContactActionSheetState extends State<_ContactActionSheet> { + late bool _isFavourite; + bool _isUpdatingFavourite = false; + + @override + void initState() { + super.initState(); + _isFavourite = widget.initialFavourite; + } + + Future _toggleFavourite() async { + final callback = widget.onToggleFavourite; + if (callback == null || _isUpdatingFavourite) { + return; + } + + setState(() { + _isUpdatingFavourite = true; + }); + + try { + await callback(); + if (!mounted) { + return; + } + setState(() { + _isFavourite = !_isFavourite; + }); + } finally { + if (mounted) { + setState(() { + _isUpdatingFavourite = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + final contact = widget.contact; + final title = contact.getLocalizedDisplayName(context); + final routeLabel = !contact.routeHasPath || contact.routeHopCount <= 0 + ? l10n.direct + : contact.routeCanonicalText; + final bottomInset = MediaQuery.of(context).viewPadding.bottom; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.88, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.12), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], + ), + child: Material( + color: colorScheme.surface, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ContactAvatar( + contact: contact, + radius: 28, + displayName: title, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.45, + ), + ), + const SizedBox(height: 4), + Text( + contact.isPublicChannel + ? l10n.broadcastToAllNearby + : contact.publicKeyShort, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: contact.isPublicChannel + ? null + : 'monospace', + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _ContactSheetChip( + icon: + contact.routeHasPath && + contact.routeHopCount > 0 + ? Icons.alt_route + : Icons.north_east_rounded, + label: routeLabel, + monospace: + contact.routeHasPath && + contact.routeHopCount > 0, + ), + ], + ), + ], + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showFavouriteButton) + IconButton.filledTonal( + onPressed: _isUpdatingFavourite + ? null + : _toggleFavourite, + tooltip: l10n.favourites, + icon: _isUpdatingFavourite + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ) + : Icon( + _isFavourite + ? Icons.star_rounded + : Icons.star_outline, + color: _isFavourite ? Colors.amber : null, + ), + ), + if (widget.showFavouriteButton) const SizedBox(width: 8), + IconButton( + onPressed: widget.onClose, + tooltip: l10n.close, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ], + ), + if (widget.primaryActions.isNotEmpty) ...[ + const SizedBox(height: 20), + LayoutBuilder( + builder: (context, constraints) { + const spacing = 12.0; + const minTileWidth = 104.0; + final actionCount = widget.primaryActions.length; + final maxColumnsByWidth = + ((constraints.maxWidth + spacing) / + (minTileWidth + spacing)) + .floor() + .clamp(1, 4); + var columnCount = actionCount.clamp(1, maxColumnsByWidth); + if (actionCount > 3 && + columnCount > 2 && + actionCount % columnCount == 1) { + columnCount -= 1; + } + final itemWidth = + (constraints.maxWidth - (spacing * (columnCount - 1))) / + columnCount; + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in widget.primaryActions) + SizedBox( + width: itemWidth, + child: _ContactPrimaryActionButton(action: action), + ), + ], + ); + }, + ), + ], + if (widget.secondaryActions.isNotEmpty) ...[ + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.others, + style: theme.textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ), + ), + Material( + color: colorScheme.surfaceContainerLow, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.28), + ), + ), + child: Column( + children: [ + for ( + var index = 0; + index < widget.secondaryActions.length; + index++ + ) + _ContactSecondaryActionTile( + action: widget.secondaryActions[index], + showDivider: + index != widget.secondaryActions.length - 1, + ), + ], + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _ContactPrimaryActionButton extends StatelessWidget { + final _ContactSheetAction action; + + const _ContactPrimaryActionButton({required this.action}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final accent = action.destructive ? colorScheme.error : colorScheme.primary; + final backgroundColor = action.destructive + ? colorScheme.errorContainer.withValues(alpha: 0.82) + : Color.alphaBlend( + accent.withValues(alpha: 0.12), + colorScheme.surfaceContainerLow, + ); + final foregroundColor = action.destructive + ? colorScheme.onErrorContainer + : accent; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: action.enabled ? action.onTap : null, + child: Ink( + height: 80, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: action.destructive + ? colorScheme.error.withValues(alpha: 0.18) + : accent.withValues(alpha: 0.14), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: foregroundColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(action.icon, color: foregroundColor, size: 15), + ), + const SizedBox(height: 6), + Text( + action.label, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: action.enabled + ? (action.destructive + ? colorScheme.onErrorContainer + : colorScheme.onSurface) + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w800, + letterSpacing: -0.1, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ContactSecondaryActionTile extends StatelessWidget { + final _ContactSheetAction action; + final bool showDivider; + + const _ContactSecondaryActionTile({ + required this.action, + required this.showDivider, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final iconColor = action.enabled + ? (action.destructive + ? colorScheme.error + : colorScheme.onSurfaceVariant) + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5); + final textColor = action.enabled + ? (action.destructive ? colorScheme.error : colorScheme.onSurface) + : colorScheme.onSurfaceVariant; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + enabled: action.enabled, + onTap: action.enabled ? action.onTap : null, + leading: Icon(action.icon, color: iconColor), + title: Text( + action.label, + style: TextStyle(color: textColor, fontWeight: FontWeight.w600), + ), + minLeadingWidth: 18, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + if (showDivider) + Divider( + height: 1, + indent: 56, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.24), + ), + ], + ); + } +} + +class _ContactSheetChip extends StatelessWidget { + final IconData icon; + final String label; + final bool monospace; + + const _ContactSheetChip({ + required this.icon, + required this.label, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + constraints: const BoxConstraints(maxWidth: 220), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + fontFamily: monospace ? 'monospace' : null, + ), + ), + ), + ], + ), + ); + } +} + +class _ContactNameOverrideSheet extends StatefulWidget { + final String initialValue; + final String advertisedName; + final String cancelLabel; + final String saveLabel; + + const _ContactNameOverrideSheet({ + required this.initialValue, + required this.advertisedName, + required this.cancelLabel, + required this.saveLabel, + }); + + @override + State<_ContactNameOverrideSheet> createState() => + _ContactNameOverrideSheetState(); +} + +class _ContactNameOverrideSheetState extends State<_ContactNameOverrideSheet> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + Navigator.of(context).pop(_controller.text); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Edit name', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Custom name', + hintText: widget.advertisedName, + helperText: 'Leave blank to use the advertised name.', + border: const OutlineInputBorder(), + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(widget.cancelLabel), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: _submit, + child: Text(widget.saveLabel), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// Bottom sheet that sends "neighbors" text command to a repeater and shows results. +class _NeighboursSheet extends StatefulWidget { + final Contact contact; + + const _NeighboursSheet({required this.contact}); + + @override + State<_NeighboursSheet> createState() => _NeighboursSheetState(); +} + +class _NeighboursSheetState extends State<_NeighboursSheet> { + bool _loading = true; + String? _error; + List<_Neighbour> _neighbours = const []; + + @override + void initState() { + super.initState(); + _fetchNeighbours(); + } + + Future _fetchNeighbours() async { + final connectionProvider = context.read(); + final previousOnMessageReceived = connectionProvider.onMessageReceived; + + String? responseText; + void onMessage(message) { + if (message.senderPublicKeyPrefix != null && + widget.contact.publicKey + .sublist(0, 6) + .every((b) => message.senderPublicKeyPrefix!.contains(b))) { + responseText = message.text; + } + } + + void sheetListener(message) { + previousOnMessageReceived?.call(message); + onMessage(message); + } + + connectionProvider.onMessageReceived = sheetListener; + + try { + await connectionProvider.sendTextMessage( + contactPublicKey: widget.contact.publicKey, + text: 'neighbors', + ); + + for (int i = 0; i < 30; i++) { + await Future.delayed(const Duration(milliseconds: 500)); + if (responseText != null) break; + } + + if (!mounted) return; + + if (responseText == null) { + setState(() { + _loading = false; + _error = 'No response from repeater (timeout)'; + }); + return; + } + + final text = responseText!; + if (text.toLowerCase().contains('unknown')) { + setState(() { + _loading = false; + _error = 'Neighbours feature not supported by this firmware'; + }); + return; + } + if (text.toLowerCase().contains('-none-') || text.trim().isEmpty) { + setState(() { + _loading = false; + }); + return; + } + + final parsed = <_Neighbour>[]; + for (final line in text.split('\n')) { + final trimmed = line.trim(); + if (trimmed.isEmpty) continue; + final parts = trimmed.split(':'); + if (parts.length >= 3) { + final keyHex = parts[0]; + final timestampOrSeconds = int.tryParse(parts[1]); + final snrRaw = int.tryParse(parts[2]); + final isDurationSeconds = + timestampOrSeconds != null && timestampOrSeconds < 1000000000; + parsed.add( + _Neighbour( + publicKeyHex: keyHex, + lastSeenAt: !isDurationSeconds && timestampOrSeconds != null + ? timestampOrSeconds >= 1000000000000 + ? DateTime.fromMillisecondsSinceEpoch( + timestampOrSeconds, + ) + : DateTime.fromMillisecondsSinceEpoch( + timestampOrSeconds * 1000, + ) + : null, + lastSeenSeconds: + isDurationSeconds ? timestampOrSeconds : null, + snrDb: snrRaw != null ? snrRaw / 4.0 : null, + ), + ); + } + } + + setState(() { + _loading = false; + _neighbours = parsed; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = 'Failed: $e'; + }); + } finally { + if (identical(connectionProvider.onMessageReceived, sheetListener)) { + connectionProvider.onMessageReceived = previousOnMessageReceived; + } + } + } + + Contact? _resolveNeighbourContact(String keyHex) { + final contactsProvider = context.read(); + for (final contact in contactsProvider.contacts) { + if (contact.publicKeyHex.toLowerCase().startsWith(keyHex.toLowerCase())) { + return contact; + } + } + return null; + } + + String _resolveNeighbourName(String keyHex) { + final contact = _resolveNeighbourContact(keyHex); + if (contact != null) { + return contact.displayName; + } + return keyHex.length > 12 ? '${keyHex.substring(0, 12)}...' : keyHex; + } + + String _formatAge(BuildContext context, _Neighbour neighbour) { + if (neighbour.lastSeenAt != null) { + return DateTime.now() + .difference(neighbour.lastSeenAt!) + .toLocalizedTimeAgoWithSeconds(context); + } + if (neighbour.lastSeenSeconds != null) { + if (neighbour.lastSeenSeconds! < 1) { + return AppLocalizations.of(context)!.justNow; + } + return Duration( + seconds: neighbour.lastSeenSeconds!, + ).toLocalizedTimeAgoWithSeconds(context); + } + return AppLocalizations.of(context)!.justNow; + } + + List<_MappedNeighbour> _mappedNeighbours() { + return _neighbours + .map((neighbour) { + final contact = _resolveNeighbourContact(neighbour.publicKeyHex); + final location = contact?.displayLocation; + if (contact == null || location == null) { + return null; + } + return _MappedNeighbour( + neighbour: neighbour, + contact: contact, + location: LatLng(location.latitude, location.longitude), + ); + }) + .whereType<_MappedNeighbour>() + .toList(); + } + + Widget _buildSummaryChip( + BuildContext context, { + required IconData icon, + required String label, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Widget _buildRepeaterMarker(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 132), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + widget.contact.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ), + ), + const SizedBox(height: 4), + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon(Icons.hub_outlined, color: Colors.white, size: 18), + ), + ], + ); + } + + Widget _buildNeighbourMarker(BuildContext context, _MappedNeighbour mapped) { + final quality = linkQualityLabel(null, mapped.neighbour.snrDb); + final qualityColor = linkQualityColor(quality); + final ageLabel = _formatAge(context, mapped.neighbour); + final signalLabel = mapped.neighbour.snrDb == null + ? quality + : '$quality • ${mapped.neighbour.snrDb!.toStringAsFixed(1)} dB'; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 146), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: qualityColor.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(999), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + signalLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 10, + ), + ), + ), + const SizedBox(height: 4), + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all(color: qualityColor, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + mapped.contact.isRepeater + ? Icons.router_outlined + : Icons.location_on_outlined, + color: qualityColor, + size: 16, + ), + ), + const SizedBox(height: 4), + Container( + constraints: const BoxConstraints(maxWidth: 148), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + mapped.contact.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ), + const SizedBox(height: 2), + Text( + ageLabel, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildRouteMap( + BuildContext context, { + required LatLng repeaterLocation, + required List<_MappedNeighbour> mappedNeighbours, + }) { + final points = [ + repeaterLocation, + ...mappedNeighbours.map((mapped) => mapped.location), + ]; + final colorScheme = Theme.of(context).colorScheme; + + return ClipRRect( + borderRadius: BorderRadius.circular(18), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.outlineVariant), + ), + child: flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCameraFit: flutter_map.CameraFit.bounds( + bounds: flutter_map.LatLngBounds.fromPoints(points), + padding: const EdgeInsets.all(42), + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.meshcore.sar', + ), + flutter_map.PolylineLayer( + polylines: mappedNeighbours.map((mapped) { + final quality = linkQualityLabel(null, mapped.neighbour.snrDb); + final qualityColor = linkQualityColor(quality); + return flutter_map.Polyline( + points: [repeaterLocation, mapped.location], + color: qualityColor.withValues(alpha: 0.9), + strokeWidth: 4, + borderColor: Colors.white.withValues(alpha: 0.7), + borderStrokeWidth: 1.5, + ); + }).toList(), + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: repeaterLocation, + width: 150, + height: 74, + child: _buildRepeaterMarker(context), + ), + ...mappedNeighbours.map( + (mapped) => flutter_map.Marker( + point: mapped.location, + width: 164, + height: 112, + child: _buildNeighbourMarker(context, mapped), + ), + ), + ], + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final repeaterDisplayLocation = widget.contact.displayLocation; + final repeaterLocation = repeaterDisplayLocation == null + ? null + : LatLng( + repeaterDisplayLocation.latitude, + repeaterDisplayLocation.longitude, + ); + final mappedNeighbours = _mappedNeighbours(); + final missingLocations = _neighbours.length - mappedNeighbours.length; + + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.72, + child: Column( + children: [ + const SizedBox(height: 12), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).dividerColor, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: Row( + children: [ + const Icon(Icons.hub_outlined), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Neighbours of ${widget.contact.displayName}', + style: Theme.of(context).textTheme.titleMedium, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(_error!, textAlign: TextAlign.center), + ), + ) + : _neighbours.isEmpty + ? Center(child: Text(AppLocalizations.of(context)!.noNeighboursFound)) + : Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildSummaryChip( + context, + icon: Icons.route_outlined, + label: + '${_neighbours.length} neighbour${_neighbours.length == 1 ? '' : 's'}', + ), + _buildSummaryChip( + context, + icon: Icons.map_outlined, + label: '${mappedNeighbours.length} on map', + ), + if (missingLocations > 0) + _buildSummaryChip( + context, + icon: Icons.location_off_outlined, + label: '$missingLocations without GPS', + ), + ], + ), + const SizedBox(height: 12), + Expanded( + child: repeaterLocation == null + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + ), + child: Text( + '${widget.contact.displayName} has no saved location, so neighbour routes cannot be drawn on the map yet.', + textAlign: TextAlign.center, + ), + ), + ) + : mappedNeighbours.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + ), + child: Text( + 'Neighbours responded, but no geolocated contacts matched saved nodes to plot. Recent neighbour: ${_resolveNeighbourName(_neighbours.first.publicKeyHex)} • ${_formatAge(context, _neighbours.first)}', + textAlign: TextAlign.center, + ), + ), + ) + : _buildRouteMap( + context, + repeaterLocation: repeaterLocation, + mappedNeighbours: mappedNeighbours, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Neighbour { + final String publicKeyHex; + final DateTime? lastSeenAt; + final int? lastSeenSeconds; + final double? snrDb; + + const _Neighbour({ + required this.publicKeyHex, + this.lastSeenAt, + this.lastSeenSeconds, + this.snrDb, + }); +} + +class _MappedNeighbour { + final _Neighbour neighbour; + final Contact contact; + final LatLng location; + + const _MappedNeighbour({ + required this.neighbour, + required this.contact, + required this.location, + }); +} + +/// Bottom sheet for pinging a relay/repeater with history and distance. +class _PingRelaySheet extends StatefulWidget { + final Contact contact; + + const _PingRelaySheet({required this.contact}); + + @override + State<_PingRelaySheet> createState() => _PingRelaySheetState(); +} + +class _PingEntry { + final RelayPingResult result; + final DateTime timestamp; + final String? distance; + + const _PingEntry({ + required this.result, + required this.timestamp, + this.distance, + }); +} + +class _PingRelaySheetState extends State<_PingRelaySheet> { + bool _pinging = false; + final List<_PingEntry> _history = []; + + @override + void initState() { + super.initState(); + _doPing(); + } + + Future _doPing() async { + setState(() => _pinging = true); + final connectionProvider = context.read(); + final distance = _distanceText(); + final result = await connectionProvider.pingRelay(widget.contact); + if (!mounted) return; + setState(() { + _pinging = false; + _history.insert( + 0, + _PingEntry( + result: result, + timestamp: DateTime.now(), + distance: distance, + ), + ); + }); + } + + String? _distanceText() { + final location = widget.contact.displayLocation; + if (location == null) return null; + final currentPosition = LocationTrackingService().currentPosition; + if (currentPosition == null) return null; + final meters = Geolocator.distanceBetween( + currentPosition.latitude, + currentPosition.longitude, + location.latitude, + location.longitude, + ); + if (meters < 1000) return '${meters.round()} m'; + if (meters < 10000) return '${(meters / 1000).toStringAsFixed(2)} km'; + return '${(meters / 1000).toStringAsFixed(1)} km'; + } + + Widget _buildPill( + BuildContext context, { + required IconData icon, + required String label, + Color? iconColor, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: iconColor ?? colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + Widget _buildSnrPill(BuildContext context, String direction, double snrDb) { + final quality = linkQualityLabel(null, snrDb); + final color = linkQualityColor(quality); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + direction == 'there' + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 12, + color: color, + ), + const SizedBox(width: 4), + Text( + '${snrDb.toStringAsFixed(1)} dB', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + Widget _buildResultRow(BuildContext context, _PingEntry entry, int seq) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final r = entry.result; + final age = DateTime.now().difference(entry.timestamp); + final timeAgo = age.toLocalizedTimeAgoWithSeconds(context); + + if (!r.success) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + CircleAvatar( + radius: 12, + backgroundColor: colorScheme.error.withValues(alpha: 0.15), + child: Text( + '$seq', + style: TextStyle( + color: colorScheme.error, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 10), + Text( + 'Timeout', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Text( + timeAgo, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 12, + backgroundColor: colorScheme.surfaceContainerHighest, + child: Text( + '$seq', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 10), + _buildPill( + context, + icon: Icons.timer_outlined, + label: '${r.durationMs} ms', + ), + const SizedBox(width: 6), + _buildSnrPill(context, 'there', r.snrThere), + const SizedBox(width: 6), + _buildSnrPill(context, 'back', r.snrBack), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 34, top: 4), + child: Row( + children: [ + if (entry.distance != null) ...[ + Icon(Icons.straighten, size: 10, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), + const SizedBox(width: 3), + Text( + entry.distance!, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + const SizedBox(width: 8), + ], + Icon(Icons.schedule, size: 10, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5)), + const SizedBox(width: 3), + Text( + timeAgo, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + ], + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final displayName = widget.contact.displayName; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.dividerColor, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: Text( + 'Ping $displayName', + style: theme.textTheme.titleLarge, + ), + ), + if (_history.isNotEmpty) + IconButton( + onPressed: () => setState(() => _history.clear()), + icon: const Icon(Icons.delete_outline, size: 20), + tooltip: AppLocalizations.of(context)!.clearHistory, + style: IconButton.styleFrom( + foregroundColor: colorScheme.onSurfaceVariant, + ), + ), + FilledButton.icon( + onPressed: _pinging ? null : _doPing, + icon: _pinging + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : const Icon(Icons.network_ping, size: 18), + label: Text(_pinging ? 'Pinging...' : 'Ping Again'), + ), + ], + ), + const SizedBox(height: 16), + if (_history.isEmpty && _pinging) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else if (_history.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text( + 'No results yet', + style: theme.textTheme.bodyMedium, + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: ListView.separated( + shrinkWrap: true, + itemCount: _history.length, + separatorBuilder: (_, _) => + Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.3)), + itemBuilder: (context, index) { + final entry = _history[index]; + final seq = _history.length - index; + return _buildResultRow(context, entry, seq); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/contacts/ping_contact_sheet.dart b/lib/widgets/contacts/ping_contact_sheet.dart new file mode 100644 index 0000000..723741d --- /dev/null +++ b/lib/widgets/contacts/ping_contact_sheet.dart @@ -0,0 +1,638 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; + +import '../../models/contact.dart'; +import '../../providers/connection_provider.dart'; +import '../../services/location_tracking_service.dart'; +import '../../utils/link_quality.dart'; +import '../../utils/time_ago_extensions.dart'; + +class PingContactSheet extends StatefulWidget { + final Contact contact; + + const PingContactSheet({super.key, required this.contact}); + + @override + State createState() => _PingContactSheetState(); +} + +class _PingEntry { + final RelayPingResult result; + final DateTime timestamp; + final String? distance; + + const _PingEntry({ + required this.result, + required this.timestamp, + this.distance, + }); +} + +class _PingContactSheetState extends State { + static const Duration _autoPingInterval = Duration(seconds: 2); + static const Duration _maxAutoPingDelay = Duration(seconds: 5); + static const int _maxHistoryEntries = 24; + static const double _maxHistoryHeight = 320; + static const Duration _timeoutPenalty = Duration(seconds: 10); + + bool _pinging = false; + bool _autoPingEnabled = false; + final List<_PingEntry> _history = []; + final _PingCuePlayer _cuePlayer = _PingCuePlayer(); + Timer? _autoPingTimer; + + @override + void initState() { + super.initState(); + _doPing(); + } + + @override + void dispose() { + _autoPingTimer?.cancel(); + _cuePlayer.dispose(); + super.dispose(); + } + + Future _doPing() async { + if (_pinging) { + return; + } + setState(() => _pinging = true); + final connectionProvider = context.read(); + final distance = _distanceText(); + final result = await connectionProvider.pingRelay(widget.contact); + if (!mounted) return; + setState(() { + _pinging = false; + _history.insert( + 0, + _PingEntry( + result: result, + timestamp: DateTime.now(), + distance: distance, + ), + ); + if (_history.length > _maxHistoryEntries) { + _history.removeRange(_maxHistoryEntries, _history.length); + } + }); + await _maybePlayCue(result); + _scheduleNextAutoPing(result); + } + + Future _maybePlayCue(RelayPingResult result) async { + if (!_autoPingEnabled) { + return; + } + + final previous = _history.length >= 2 ? _history[1].result : null; + await _cuePlayer.playCue(result, previous: previous); + } + + void _setAutoPingEnabled(bool enabled) { + setState(() { + _autoPingEnabled = enabled; + }); + _autoPingTimer?.cancel(); + if (!enabled) { + return; + } + _scheduleNextAutoPing(); + } + + void _scheduleNextAutoPing([RelayPingResult? result]) { + _autoPingTimer?.cancel(); + if (!_autoPingEnabled || !mounted) { + return; + } + final responseDelay = result == null + ? Duration.zero + : result.success + ? Duration(milliseconds: result.durationMs) + : _timeoutPenalty; + final nextDelay = _autoPingInterval + responseDelay; + final clampedDelay = nextDelay > _maxAutoPingDelay + ? _maxAutoPingDelay + : nextDelay; + _autoPingTimer = Timer(clampedDelay, () { + unawaited(_doPing()); + }); + } + + String? _distanceText() { + final location = widget.contact.displayLocation; + if (location == null) return null; + final currentPosition = LocationTrackingService().currentPosition; + if (currentPosition == null) return null; + final meters = Geolocator.distanceBetween( + currentPosition.latitude, + currentPosition.longitude, + location.latitude, + location.longitude, + ); + if (meters < 1000) return '${meters.round()} m'; + if (meters < 10000) return '${(meters / 1000).toStringAsFixed(2)} km'; + return '${(meters / 1000).toStringAsFixed(1)} km'; + } + + Widget _buildPill( + BuildContext context, { + required IconData icon, + required String label, + Color? iconColor, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: iconColor ?? colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + Widget _buildSnrPill(BuildContext context, String direction, double snrDb) { + final quality = linkQualityLabel(null, snrDb); + final color = linkQualityColor(quality); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + direction == 'there' + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 12, + color: color, + ), + const SizedBox(width: 4), + Text( + '${snrDb.toStringAsFixed(1)} dB', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + Widget _buildResultRow(BuildContext context, _PingEntry entry, int seq) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final r = entry.result; + final age = DateTime.now().difference(entry.timestamp); + final timeAgo = age.toLocalizedTimeAgoWithSeconds(context); + + if (!r.success) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + CircleAvatar( + radius: 12, + backgroundColor: colorScheme.error.withValues(alpha: 0.15), + child: Text( + '$seq', + style: TextStyle( + color: colorScheme.error, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 10), + Text( + 'Timeout', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Text( + timeAgo, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 12, + backgroundColor: colorScheme.surfaceContainerHighest, + child: Text( + '$seq', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 10), + _buildPill( + context, + icon: Icons.timer_outlined, + label: '${r.durationMs} ms', + ), + const SizedBox(width: 6), + _buildSnrPill(context, 'there', r.snrThere), + const SizedBox(width: 6), + _buildSnrPill(context, 'back', r.snrBack), + const Spacer(), + Text( + timeAgo, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 34, top: 4), + child: Row( + children: [ + if (entry.distance != null) ...[ + Icon( + Icons.straighten, + size: 10, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(width: 3), + Text( + entry.distance!, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + const SizedBox(width: 8), + ], + ], + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Ping ${widget.contact.displayName}', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + FilledButton.icon( + onPressed: _pinging ? null : _doPing, + icon: _pinging + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : const Icon(Icons.network_ping, size: 18), + label: Text(_pinging ? 'Pinging...' : 'Ping again'), + ), + ], + ), + const SizedBox(height: 8), + Text( + widget.contact.publicKeyShort, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Row( + children: [ + Icon( + _autoPingEnabled + ? Icons.radar_rounded + : Icons.radar_outlined, + color: _autoPingEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Auto ping', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + '2s sonar cues for there/back SNR', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Switch.adaptive( + value: _autoPingEnabled, + onChanged: _setAutoPingEnabled, + ), + ], + ), + ), + const SizedBox(height: 16), + if (_history.isEmpty && !_pinging) + Text( + 'No ping results yet.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: _maxHistoryHeight, + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: _history.length, + separatorBuilder: (_, _) => Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + itemBuilder: (context, index) => + _buildResultRow(context, _history[index], index + 1), + ), + ), + ], + ), + ), + ); + } +} + +class _PingCuePlayer { + static const double _fairSnrThresholdDb = 0; + + AudioPlayer? _player; + + Future playCue( + RelayPingResult result, { + RelayPingResult? previous, + }) async { + _player ??= AudioPlayer(); + final player = _player!; + final filePath = await _writeCueFile( + result, + previous: previous, + ); + await player.stop(); + await player.setVolume(1.0); + await player.play(DeviceFileSource(filePath)); + } + + Future _writeCueFile( + RelayPingResult result, { + RelayPingResult? previous, + }) async { + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/ping_sonar_alert.wav'); + final samples = _buildCueSamples(result, previous: previous); + final wavBytes = _buildWav(samples, sampleRate: 16000); + await file.writeAsBytes(wavBytes, flush: true); + return file.path; + } + + Int16List _buildCueSamples( + RelayPingResult result, { + RelayPingResult? previous, + }) { + const sampleRate = 16000; + final segments = []; + if (!result.success) { + segments.addAll( + _timeoutAlert(sampleRate), + ); + } else { + segments.addAll( + _tone( + sampleRate, + frequencyHz: _frequencyForSnr(result.snrThere), + durationMs: 90, + amplitude: 0.42, + ), + ); + segments.addAll(_silence(sampleRate, durationMs: 35)); + segments.addAll( + _tone( + sampleRate, + frequencyHz: _frequencyForSnr(result.snrBack), + durationMs: 90, + amplitude: 0.42, + ), + ); + final belowFair = + result.snrThere < _fairSnrThresholdDb && + result.snrBack < _fairSnrThresholdDb; + final worsening = + previous != null && + result.snrThere < previous.snrThere && + result.snrBack < previous.snrBack; + if (belowFair && worsening) { + segments.addAll(_silence(sampleRate, durationMs: 40)); + segments.addAll( + _descendingAlert(sampleRate, baseFrequencyHz: 520), + ); + } + } + final pcm = Int16List(segments.length); + for (var i = 0; i < segments.length; i++) { + pcm[i] = (segments[i] * 32767).round().clamp(-32768, 32767); + } + return pcm; + } + + double _frequencyForSnr(double snrDb) { + final clamped = snrDb.clamp(-12.0, 12.0); + final normalized = (clamped + 12.0) / 24.0; + return 320 + (normalized * 900); + } + + List _descendingAlert(int sampleRate, {required double baseFrequencyHz}) { + return [ + ..._tone( + sampleRate, + frequencyHz: baseFrequencyHz, + durationMs: 80, + amplitude: 0.44, + ), + ..._silence(sampleRate, durationMs: 30), + ..._tone( + sampleRate, + frequencyHz: baseFrequencyHz * 0.82, + durationMs: 110, + amplitude: 0.40, + ), + ]; + } + + List _timeoutAlert(int sampleRate) { + return [ + ..._tone( + sampleRate, + frequencyHz: 240, + durationMs: 180, + amplitude: 0.46, + ), + ..._silence(sampleRate, durationMs: 60), + ..._tone( + sampleRate, + frequencyHz: 180, + durationMs: 220, + amplitude: 0.52, + ), + ]; + } + + List _tone( + int sampleRate, { + required double frequencyHz, + required int durationMs, + required double amplitude, + }) { + final sampleCount = (sampleRate * durationMs / 1000).round(); + return List.generate(sampleCount, (index) { + final t = index / sampleRate; + final envelope = math.sin(math.pi * index / sampleCount); + return math.sin(2 * math.pi * frequencyHz * t) * amplitude * envelope; + }); + } + + List _silence(int sampleRate, {required int durationMs}) { + final sampleCount = (sampleRate * durationMs / 1000).round(); + return List.filled(sampleCount, 0); + } + + Uint8List _buildWav(Int16List samples, {required int sampleRate}) { + const numChannels = 1; + const bitsPerSample = 16; + const audioFormat = 1; + final dataSize = samples.length * 2; + final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8; + final blockAlign = numChannels * bitsPerSample ~/ 8; + final totalSize = 36 + dataSize; + final buffer = ByteData(44 + dataSize); + var offset = 0; + + void writeString(String value) { + for (final codeUnit in value.codeUnits) { + buffer.setUint8(offset++, codeUnit); + } + } + + void writeUint32(int value) { + buffer.setUint32(offset, value, Endian.little); + offset += 4; + } + + void writeUint16(int value) { + buffer.setUint16(offset, value, Endian.little); + offset += 2; + } + + writeString('RIFF'); + writeUint32(totalSize); + writeString('WAVE'); + writeString('fmt '); + writeUint32(16); + writeUint16(audioFormat); + writeUint16(numChannels); + writeUint32(sampleRate); + writeUint32(byteRate); + writeUint16(blockAlign); + writeUint16(bitsPerSample); + writeString('data'); + writeUint32(dataSize); + + for (final sample in samples) { + buffer.setInt16(offset, sample, Endian.little); + offset += 2; + } + + return buffer.buffer.asUint8List(); + } + + void dispose() { + _player?.dispose(); + } +} diff --git a/lib/widgets/contacts/room_login_sheet.dart b/lib/widgets/contacts/room_login_sheet.dart new file mode 100644 index 0000000..5162215 --- /dev/null +++ b/lib/widgets/contacts/room_login_sheet.dart @@ -0,0 +1,513 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; + +class RoomLoginSheet extends StatefulWidget { + final Contact contact; + + const RoomLoginSheet({super.key, required this.contact}); + + @override + State createState() => _RoomLoginSheetState(); +} + +class _RoomLoginSheetState extends State { + final TextEditingController _passwordController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + bool _isLoggingIn = false; + bool _obscurePassword = true; + + @override + void initState() { + super.initState(); + _loadSavedPassword(); + } + + @override + void dispose() { + _passwordController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + /// Load saved password for this room + Future _loadSavedPassword() async { + final prefs = await SharedPreferences.getInstance(); + final roomKey = 'room_password_${widget.contact.publicKeyHex}'; + final savedPassword = prefs.getString(roomKey); + if (savedPassword != null) { + _passwordController.text = savedPassword; + } + } + + /// Save password for this room + Future _savePassword(String password) async { + final prefs = await SharedPreferences.getInstance(); + final roomKey = 'room_password_${widget.contact.publicKeyHex}'; + await prefs.setString(roomKey, password); + } + + Future _loginToRoom() async { + final password = _passwordController.text.trim(); + + final connectionProvider = context.read(); + final contactsProvider = context.read(); + + if (password.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.pleaseEnterPassword), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } + + if (!connectionProvider.deviceInfo.isConnected) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.deviceNotConnected), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } + + setState(() { + _isLoggingIn = true; + }); + + // 🕐 CLOCK DRIFT CHECK: Get device time to detect synchronization issues + debugPrint( + '🕐 [RoomLogin] Checking for clock drift between app and radio...', + ); + try { + await connectionProvider.getDeviceTime(); + // Give time for response to be logged + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + debugPrint('⚠️ [RoomLogin] Failed to get device time: $e'); + // Don't fail login - this is just a diagnostic check + } + + // 🔍 PRE-LOGIN CHECK: Ensure room contact exists in device + debugPrint( + '🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...', + ); + debugPrint( + ' Target public key prefix: ${widget.contact.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}', + ); + + // Check if the room exists in our local contacts + bool roomExists = contactsProvider.rooms.any( + (room) => room.publicKeyHex == widget.contact.publicKeyHex, + ); + + debugPrint( + ' Local contact list: ${roomExists ? "✅ Found" : "❌ Not found"}', + ); + + if (!roomExists) { + debugPrint( + '⚠️ [RoomLogin] Room not in local contacts - syncing with device...', + ); + + try { + // Sync contacts from device + await connectionProvider.getContacts(); + + // Give time for contacts to be processed + await Future.delayed(const Duration(milliseconds: 800)); + + // Check again after sync + roomExists = contactsProvider.rooms.any( + (room) => room.publicKeyHex == widget.contact.publicKeyHex, + ); + + debugPrint( + ' After sync: ${roomExists ? "✅ Found" : "❌ Still not found"}', + ); + + if (!roomExists) { + // Room still doesn't exist on the device - try to add it manually + debugPrint('❌ [RoomLogin] Room still not found after sync'); + debugPrint( + '🔧 [RoomLogin] Attempting to add room contact to companion radio...', + ); + + try { + // Manually add the room contact to the radio's flash storage + await connectionProvider.addOrUpdateContact(widget.contact); + final addError = connectionProvider.error; + if (addError != null) { + throw Exception(addError); + } + + debugPrint( + '✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT', + ); + debugPrint(' Waiting 500ms for radio to save to flash...'); + + // Give the radio time to save the contact to flash + await Future.delayed(const Duration(milliseconds: 500)); + + debugPrint( + '✅ [RoomLogin] Room contact should now be available - proceeding with login', + ); + } catch (e) { + debugPrint('❌ [RoomLogin] Failed to add room contact: $e'); + + if (!mounted) return; + + setState(() { + _isLoggingIn = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.failedToAddRoom(e.toString()), + ), + backgroundColor: Theme.of(context).colorScheme.error, + duration: const Duration(seconds: 7), + ), + ); + + // Log available rooms for debugging + final availableRooms = contactsProvider.rooms; + debugPrint( + '📋 [RoomLogin] Available rooms on device (${availableRooms.length}):', + ); + for (final room in availableRooms) { + debugPrint( + ' - ${room.advName} (${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')})', + ); + } + + return; + } + } + + debugPrint( + '✅ [RoomLogin] Room contact found after sync - proceeding with login', + ); + } catch (e) { + debugPrint('❌ [RoomLogin] Contact sync failed: $e'); + + if (!mounted) return; + + setState(() { + _isLoggingIn = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.failedToSyncContacts(e.toString()), + ), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + return; + } + } else { + debugPrint( + '✅ [RoomLogin] Room contact found in local contacts - proceeding with login', + ); + } + + // Save password before sending + await _savePassword(password); + + if (!mounted) return; + + // Capture references before the sheet pops: the login result arrives + // after Navigator.pop, when this State is disposed, so the callbacks + // below must not depend on this widget's context. + final messenger = ScaffoldMessenger.of(context); + final colorScheme = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + + // Set up login callbacks + Function(Uint8List, int, bool, int)? originalOnSuccess; + Function(Uint8List)? originalOnFail; + + originalOnSuccess = connectionProvider.onLoginSuccess; + originalOnFail = connectionProvider.onLoginFail; + + connectionProvider + .onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async { + // Restore original callback + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + debugPrint( + '✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin', + ); + debugPrint( + '📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING', + ); + debugPrint( + ' Messages will be fetched when onMessageWaiting callback is triggered', + ); + + // Use the captured messenger: the sheet has already been popped by the + // time this callback fires, so this State's context is gone. + messenger.showSnackBar( + SnackBar( + content: Text(l10n.loggedInSuccessfully), + backgroundColor: colorScheme.primary, + duration: const Duration(seconds: 3), + ), + ); + }; + + connectionProvider.onLoginFail = (publicKeyPrefix) { + // Restore original callback + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + debugPrint('❌ [RoomLogin] Login failed - incorrect password'); + + // Use the captured messenger: the sheet has already been popped by the + // time this callback fires, so this State's context is gone. + messenger.showSnackBar( + SnackBar( + content: Text(l10n.loginFailed), + backgroundColor: colorScheme.error, + duration: const Duration(seconds: 3), + ), + ); + }; + + try { + // Send login request to room + await connectionProvider.loginToRoom( + roomPublicKey: widget.contact.publicKey, + password: password, + ); + + _focusNode.unfocus(); + + if (mounted) { + Navigator.pop(context); // Close the dialog + } + + messenger.showSnackBar( + SnackBar( + content: Text(l10n.loggingIn(widget.contact.displayName)), + backgroundColor: colorScheme.primary, + duration: const Duration(seconds: 2), + ), + ); + } catch (e) { + // Restore original callbacks on error + connectionProvider.onLoginSuccess = originalOnSuccess; + connectionProvider.onLoginFail = originalOnFail; + + messenger.showSnackBar( + SnackBar( + content: Text(l10n.failedToSendLogin(e.toString())), + backgroundColor: colorScheme.error, + ), + ); + } finally { + if (mounted) { + setState(() { + _isLoggingIn = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context)!.loginToRoom, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + widget.contact.displayName, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(width: 48), // Balance the back button + ], + ), + ), + + // Scrollable content area + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + // Info banner + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + color: colorScheme.onPrimaryContainer, + size: 20, + ), + SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of(context)!.enterPasswordInfo, + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontSize: 12, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + + // Password input (fixed at bottom) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _passwordController, + focusNode: _focusNode, + maxLength: 15, // Max password length from protocol + obscureText: _obscurePassword, + autofocus: true, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + style: TextStyle(color: colorScheme.onSurface), + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.password, + labelStyle: TextStyle( + color: colorScheme.onSurfaceVariant, + ), + hintText: AppLocalizations.of(context)!.enterRoomPassword, + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.primary, + width: 2, + ), + ), + contentPadding: const EdgeInsets.all(16), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility + : Icons.visibility_off, + color: colorScheme.onSurfaceVariant, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _loginToRoom(), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoggingIn ? null : _loginToRoom, + icon: _isLoggingIn + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(Icons.login), + label: Text( + _isLoggingIn + ? AppLocalizations.of(context)!.loggingInDots + : AppLocalizations.of(context)!.login, + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/contacts/section_header.dart b/lib/widgets/contacts/section_header.dart new file mode 100644 index 0000000..22bbb3e --- /dev/null +++ b/lib/widgets/contacts/section_header.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +class SectionHeader extends StatelessWidget { + final String title; + final int count; + final IconData icon; + + const SectionHeader({ + super.key, + required this.title, + required this.count, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(icon, size: 20), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + count.toString(), + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/drawing_minimap_preview.dart b/lib/widgets/drawing_minimap_preview.dart new file mode 100644 index 0000000..e418beb --- /dev/null +++ b/lib/widgets/drawing_minimap_preview.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:latlong2/latlong.dart'; +import '../models/map_drawing.dart'; +import '../models/map_coordinate_space.dart'; + +/// Minimap preview widget for map drawings +/// Renders a small 80x80px preview of a drawing on a map background +class DrawingMinimapPreview extends StatelessWidget { + final MapDrawing drawing; + final Widget? tileLayer; + + const DrawingMinimapPreview({ + super.key, + required this.drawing, + this.tileLayer, + }); + + /// Calculate bounds for the drawing to fit in the preview + flutter_map.LatLngBounds _calculateBounds() { + if (drawing is LineDrawing) { + final lineDrawing = drawing as LineDrawing; + if (lineDrawing.points.isEmpty) { + // Fallback to default bounds if no points + return flutter_map.LatLngBounds( + const LatLng(0, 0), + const LatLng(0.01, 0.01), + ); + } + + // Calculate bounds from points + double minLat = lineDrawing.points.first.latitude; + double maxLat = lineDrawing.points.first.latitude; + double minLon = lineDrawing.points.first.longitude; + double maxLon = lineDrawing.points.first.longitude; + + for (final point in lineDrawing.points) { + if (point.latitude < minLat) minLat = point.latitude; + if (point.latitude > maxLat) maxLat = point.latitude; + if (point.longitude < minLon) minLon = point.longitude; + if (point.longitude > maxLon) maxLon = point.longitude; + } + + // Add padding (10% on each side) + final latPadding = (maxLat - minLat) * 0.1; + final lonPadding = (maxLon - minLon) * 0.1; + + return flutter_map.LatLngBounds( + LatLng(minLat - latPadding, minLon - lonPadding), + LatLng(maxLat + latPadding, maxLon + lonPadding), + ); + } else if (drawing is RectangleDrawing) { + final rectDrawing = drawing as RectangleDrawing; + + // Add padding (10% on each side) + final latDiff = + (rectDrawing.bottomRight.latitude - rectDrawing.topLeft.latitude) + .abs(); + final lonDiff = + (rectDrawing.bottomRight.longitude - rectDrawing.topLeft.longitude) + .abs(); + final latPadding = latDiff * 0.1; + final lonPadding = lonDiff * 0.1; + + return flutter_map.LatLngBounds( + LatLng( + rectDrawing.topLeft.latitude - latPadding, + rectDrawing.topLeft.longitude - lonPadding, + ), + LatLng( + rectDrawing.bottomRight.latitude + latPadding, + rectDrawing.bottomRight.longitude + lonPadding, + ), + ); + } + + // Fallback to default bounds + return flutter_map.LatLngBounds( + const LatLng(0, 0), + const LatLng(0.01, 0.01), + ); + } + + @override + Widget build(BuildContext context) { + final bounds = _calculateBounds(); + final isCustomMap = drawing.coordinateSpace == MapCoordinateSpace.customMap; + final drawingPoints = drawing is LineDrawing + ? (drawing as LineDrawing).points + : drawing is RectangleDrawing + ? (drawing as RectangleDrawing).corners + : const []; + + return Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade400, width: 1), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: flutter_map.FlutterMap( + options: flutter_map.MapOptions( + crs: isCustomMap + ? const flutter_map.CrsSimple() + : const flutter_map.Epsg3857(), + initialCameraFit: flutter_map.CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(8), + ), + interactionOptions: const flutter_map.InteractionOptions( + flags: + flutter_map.InteractiveFlag.none, // Disable all interactions + ), + ), + children: [ + // Use provided tile layer or fallback to gray background + if (!isCustomMap && tileLayer != null) + tileLayer! + else + Container(color: Colors.grey.shade300), + + // Render the drawing + if (drawing is LineDrawing) + flutter_map.PolylineLayer( + polylines: [ + flutter_map.Polyline( + points: drawingPoints, + strokeWidth: 3.0, + color: drawing.color, + ), + ], + ) + else if (drawing is RectangleDrawing) + flutter_map.PolygonLayer( + polygons: [ + flutter_map.Polygon( + points: drawingPoints, + color: drawing.color.withValues(alpha: 0.3), + borderColor: drawing.color, + borderStrokeWidth: 3.0, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/map/compass/compass_contact_list.dart b/lib/widgets/map/compass/compass_contact_list.dart new file mode 100644 index 0000000..b80a551 --- /dev/null +++ b/lib/widgets/map/compass/compass_contact_list.dart @@ -0,0 +1,315 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../models/contact.dart'; + +/// Contact list section for the compass dialog. +/// Shows all contacts with location sorted by distance with bearing information. +/// Splits contacts by type: Persons/Team, Repeaters, and Rooms. +class CompassContactList extends StatelessWidget { + final List contacts; + final Position? position; + final double? heading; + final Contact? selectedContact; + final bool showContacts; + final bool showRepeaters; + final ValueChanged onContactTap; + + const CompassContactList({ + super.key, + required this.contacts, + required this.position, + this.heading, + required this.selectedContact, + required this.showContacts, + required this.showRepeaters, + required this.onContactTap, + }); + + @override + Widget build(BuildContext context) { + if (contacts.isEmpty) { + return const SizedBox.shrink(); + } + + if (position == null) { + return Text(AppLocalizations.of(context)!.locationUnavailable); + } + + final l10n = AppLocalizations.of(context)!; + + // Split contacts by type + final persons = >[]; + final repeaters = >[]; + final sensors = >[]; + final rooms = >[]; + + // Calculate bearings and distances for each contact + for (final contact in contacts) { + if (contact.displayLocation == null) continue; + + final bearing = _calculateBearing( + position!.latitude, + position!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + + final distance = _calculateDistance( + position!.latitude, + position!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + + final item = { + 'contact': contact, + 'bearing': bearing, + 'distance': distance, + }; + + if (contact.isRepeater) { + repeaters.add(item); + } else if (contact.isSensor) { + sensors.add(item); + } else if (contact.isRoom) { + rooms.add(item); + } else { + persons.add(item); + } + } + + // Sort each list by distance + persons.sort( + (a, b) => (a['distance'] as double).compareTo(b['distance'] as double), + ); + repeaters.sort( + (a, b) => (a['distance'] as double).compareTo(b['distance'] as double), + ); + sensors.sort( + (a, b) => (a['distance'] as double).compareTo(b['distance'] as double), + ); + rooms.sort( + (a, b) => (a['distance'] as double).compareTo(b['distance'] as double), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Persons/Team section + if (showContacts && persons.isNotEmpty) ...[ + Padding( + padding: EdgeInsets.only(left: 16, bottom: 8, top: 4), + child: Text( + l10n.teamMembers, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ...persons.map( + (item) => _buildContactTile( + context, + item, + Icons.groups, + Theme.of(context).colorScheme.primary, + ), + ), + ], + if (showContacts && sensors.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12), + child: Text( + 'Sensors', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ...sensors.map( + (item) => + _buildContactTile(context, item, Icons.sensors, Colors.green), + ), + ], + // Repeaters section + if (showRepeaters && repeaters.isNotEmpty) ...[ + Padding( + padding: EdgeInsets.only(left: 16, bottom: 8, top: 12), + child: Text( + l10n.repeaters, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ...repeaters.map( + (item) => + _buildContactTile(context, item, Icons.router, Colors.purple), + ), + ], + // Rooms section + if (rooms.isNotEmpty) ...[ + Padding( + padding: EdgeInsets.only(left: 16, bottom: 8, top: 12), + child: Text( + l10n.rooms, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ...rooms.map( + (item) => _buildContactTile( + context, + item, + Icons.meeting_room, + Colors.teal, + ), + ), + ], + ], + ); + } + + Widget _buildContactTile( + BuildContext context, + Map item, + IconData defaultIcon, + Color iconColor, + ) { + final contact = item['contact'] as Contact; + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: selectedContact == contact + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: selectedContact == contact + ? Border.all(color: Theme.of(context).colorScheme.primary, width: 2) + : null, + ), + child: ListTile( + dense: true, + leading: contact.roleEmoji != null + ? Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24)) + : Icon(defaultIcon, color: iconColor, size: 24), + title: Text(contact.displayName), + subtitle: Text( + '${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${bearing.round()}°', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold), + ), + if (heading != null) + Text( + _formatRelativeBearing(bearing, heading!, context), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.grey), + ), + ], + ), + onTap: () { + if (selectedContact == contact) { + // Deselect if already selected + onContactTap(null); + } else { + // Select this contact + onContactTap(contact); + } + }, + ), + ); + } + + // Calculate bearing between two points (in degrees) + double _calculateBearing(double lat1, double lon1, double lat2, double lon2) { + final dLon = (lon2 - lon1) * pi / 180; + final lat1Rad = lat1 * pi / 180; + final lat2Rad = lat2 * pi / 180; + + final y = sin(dLon) * cos(lat2Rad); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + + final bearing = atan2(y, x) * 180 / pi; + return (bearing + 360) % 360; + } + + // Calculate distance between two points (in meters) + double _calculateDistance( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + + final a = + sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + String _bearingToCardinal(double bearing) { + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + final index = ((bearing + 22.5) / 45).floor() % 8; + return directions[index]; + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } + + String _formatRelativeBearing( + double bearing, + double heading, + BuildContext context, + ) { + final l10n = AppLocalizations.of(context)!; + // Calculate relative bearing (how much to turn from current heading) + double relative = bearing - heading; + + // Normalize to -180 to +180 + while (relative > 180) { + relative -= 360; + } + while (relative < -180) { + relative += 360; + } + + final absRelative = relative.abs().round(); + + if (absRelative < 10) { + return l10n.ahead; + } else if (relative > 0) { + return l10n.degreesRight(absRelative); + } else { + return l10n.degreesLeft(absRelative); + } + } +} diff --git a/lib/widgets/map/compass/compass_filters.dart b/lib/widgets/map/compass/compass_filters.dart new file mode 100644 index 0000000..b10523d --- /dev/null +++ b/lib/widgets/map/compass/compass_filters.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../providers/map_provider.dart'; + +/// Filter controls for the compass dialog. +/// Allows filtering of contacts and SAR marker types. +class CompassFilters extends StatefulWidget { + final bool showContacts; + final bool showRepeaters; + final bool showFoundPerson; + final bool showFire; + final bool showStagingArea; + final ValueChanged onShowContactsChanged; + final ValueChanged onShowRepeatersChanged; + final ValueChanged onShowFoundPersonChanged; + final ValueChanged onShowFireChanged; + final ValueChanged onShowStagingAreaChanged; + final VoidCallback onShowAll; + + const CompassFilters({ + super.key, + required this.showContacts, + required this.showRepeaters, + required this.showFoundPerson, + required this.showFire, + required this.showStagingArea, + required this.onShowContactsChanged, + required this.onShowRepeatersChanged, + required this.onShowFoundPersonChanged, + required this.onShowFireChanged, + required this.onShowStagingAreaChanged, + required this.onShowAll, + }); + + @override + State createState() => _CompassFiltersState(); +} + +class _CompassFiltersState extends State { + void _showFilterDialog() { + final l10n = AppLocalizations.of(context)!; + final mapProvider = Provider.of(context, listen: false); + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.filter_list, size: 20), + SizedBox(width: 8), + Text(l10n.filterMarkers), + ], + ), + contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Trail visibility toggle + _CompactFilterItem( + icon: Icons.timeline, + color: Colors.blue, + label: l10n.locationTrail, + value: mapProvider.isTrailVisible, + onChanged: (value) { + mapProvider.toggleTrailVisibility(); + setDialogState(() {}); + }, + ), + const SizedBox(height: 4), + const Divider(height: 8), + const SizedBox(height: 4), + // Contacts filter + _CompactFilterItem( + icon: Icons.person, + color: Theme.of(context).colorScheme.primary, + label: l10n.contactsFilter, + value: widget.showContacts, + onChanged: (value) { + widget.onShowContactsChanged(value); + setDialogState(() {}); + }, + ), + const SizedBox(height: 4), + // Repeaters filter + _CompactFilterItem( + icon: Icons.router, + color: Colors.purple, + label: l10n.repeatersFilter, + value: widget.showRepeaters, + onChanged: (value) { + widget.onShowRepeatersChanged(value); + setDialogState(() {}); + }, + ), + const SizedBox(height: 4), + const Divider(height: 8), + const SizedBox(height: 4), + // SAR Markers section + Padding( + padding: EdgeInsets.only(left: 8, bottom: 8, top: 4), + child: Text( + l10n.sarMarkers, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + _CompactFilterItem( + icon: Icons.person_pin, + color: Colors.green, + label: l10n.foundPerson, + value: widget.showFoundPerson, + onChanged: (value) { + widget.onShowFoundPersonChanged(value); + setDialogState(() {}); + }, + ), + SizedBox(height: 4), + _CompactFilterItem( + icon: Icons.local_fire_department, + color: Colors.red, + label: l10n.fire, + value: widget.showFire, + onChanged: (value) { + widget.onShowFireChanged(value); + setDialogState(() {}); + }, + ), + SizedBox(height: 4), + _CompactFilterItem( + icon: Icons.home_work, + color: Colors.orange, + label: l10n.stagingArea, + value: widget.showStagingArea, + onChanged: (value) { + widget.onShowStagingAreaChanged(value); + setDialogState(() {}); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + widget.onShowAll(); + setDialogState(() {}); + }, + child: Text(l10n.showAll), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return IconButton( + icon: Icon(Icons.filter_list), + tooltip: l10n.filterMarkersTooltip, + onPressed: () => _showFilterDialog(), + ); + } +} + +/// Compact filter item widget +class _CompactFilterItem extends StatelessWidget { + final IconData icon; + final Color color; + final String label; + final bool value; + final ValueChanged onChanged; + + const _CompactFilterItem({ + required this.icon, + required this.color, + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onChanged(!value), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Checkbox( + value: value, + onChanged: (val) => onChanged(val ?? false), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/map/compass/compass_header.dart b/lib/widgets/map/compass/compass_header.dart new file mode 100644 index 0000000..f0e0f70 --- /dev/null +++ b/lib/widgets/map/compass/compass_header.dart @@ -0,0 +1,619 @@ +import 'dart:math'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../models/contact.dart'; +import '../../../models/sar_marker.dart'; +import '../../../utils/location_formats.dart'; +import 'compass_math.dart'; + +/// Header component for the compass dialog showing compass rose, +/// heading, elevation, accuracy, and current location in multiple formats. +class CompassHeader extends StatelessWidget { + final double? heading; + final Position? position; + final bool hasHeading; + final Position? currentPosition; + final List contacts; + final List sarMarkers; + final double zoomLevel; + final double previousScale; + final ValueChanged onZoomUpdate; + final VoidCallback onScaleStart; + final VoidCallback onScaleEnd; + + const CompassHeader({ + super.key, + required this.heading, + required this.position, + required this.hasHeading, + required this.currentPosition, + required this.contacts, + required this.sarMarkers, + required this.zoomLevel, + required this.previousScale, + required this.onZoomUpdate, + required this.onScaleStart, + required this.onScaleEnd, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Heading and Elevation info + _buildInfoRow(context, heading, position), + const SizedBox(height: 12), + // Current location in multiple formats + if (position != null) _LocationFormatToggle(position: position), + const SizedBox(height: 12), + // Large compass with zoom controls + GestureDetector( + onScaleStart: (details) { + onScaleStart(); + }, + onScaleUpdate: (details) { + onZoomUpdate(details.scale); + }, + onScaleEnd: (details) { + onScaleEnd(); + }, + child: SizedBox( + width: 300, + height: 300, + child: _DetailedCompassPainter( + heading: heading ?? 0, + hasHeading: hasHeading, + currentPosition: currentPosition, + contacts: contacts, + sarMarkers: sarMarkers, + zoomLevel: zoomLevel, + ), + ), + ), + ], + ); + } + + Widget _buildInfoRow( + BuildContext context, + double? heading, + Position? position, + ) { + final l10n = AppLocalizations.of(context)!; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildInfoCard( + context, + l10n.heading, + heading != null ? '${heading.round()}°' : '--', + Icons.explore, + ), + _buildInfoCard( + context, + l10n.elevation, + position?.altitude != null ? '${position!.altitude.round()}m' : '--', + Icons.terrain, + ), + _buildInfoCard( + context, + l10n.accuracy, + position?.accuracy != null ? '±${position!.accuracy.round()}m' : '--', + Icons.gps_fixed, + ), + ], + ); + } + + Widget _buildInfoCard( + BuildContext context, + String label, + String value, + IconData icon, + ) { + return Column( + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 4), + Text( + value, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ); + } +} + +/// Detailed Compass Painter with contacts +class _DetailedCompassPainter extends StatelessWidget { + final double heading; + final bool hasHeading; + final Position? currentPosition; + final List contacts; + final List sarMarkers; + final double zoomLevel; + + const _DetailedCompassPainter({ + required this.heading, + required this.hasHeading, + required this.currentPosition, + required this.contacts, + required this.sarMarkers, + this.zoomLevel = 1.0, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _LargeCompassPainter( + heading: heading, + hasHeading: hasHeading, + currentPosition: currentPosition, + contacts: contacts, + sarMarkers: sarMarkers, + zoomLevel: zoomLevel, + ), + child: Container(), + ); + } +} + +class _LargeCompassPainter extends CustomPainter { + final double heading; + final bool hasHeading; + final Position? currentPosition; + final List contacts; + final List sarMarkers; + final double zoomLevel; + + _LargeCompassPainter({ + required this.heading, + required this.hasHeading, + required this.currentPosition, + required this.contacts, + required this.sarMarkers, + this.zoomLevel = 1.0, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2; + + // Draw outer circle + final circlePaint = Paint() + ..color = Colors.grey.withValues(alpha: 0.2) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(center, radius, circlePaint); + + // Draw degree markers + for (int i = 0; i < 360; i += 10) { + final angle = compassDialAngleRadians( + markerDegrees: i.toDouble(), + headingDegrees: heading, + ); + final isCardinal = i % 90 == 0; + final isMajor = i % 30 == 0; + + final startRadius = isCardinal + ? radius - 25 + : (isMajor ? radius - 15 : radius - 10); + final start = Offset( + center.dx + startRadius * cos(angle), + center.dy + startRadius * sin(angle), + ); + final end = Offset( + center.dx + radius * cos(angle), + center.dy + radius * sin(angle), + ); + + final markerPaint = Paint() + ..color = isCardinal ? Colors.red : Colors.grey + ..strokeWidth = isCardinal ? 3 : (isMajor ? 2 : 1); + + canvas.drawLine(start, end, markerPaint); + } + + // Draw cardinal directions + final textPainter = TextPainter(textDirection: TextDirection.ltr); + final directions = ['N', 'E', 'S', 'W']; + for (int i = 0; i < 4; i++) { + final angle = compassDialAngleRadians( + markerDegrees: (i * 90).toDouble(), + headingDegrees: heading, + ); + final x = center.dx + (radius - 35) * cos(angle); + final y = center.dy + (radius - 35) * sin(angle); + + textPainter.text = TextSpan( + text: directions[i], + style: TextStyle( + color: i == 0 ? Colors.red : Colors.grey.shade700, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(x - textPainter.width / 2, y - textPainter.height / 2), + ); + } + + // Draw contacts as dots relative to distance, scaled by zoom level + if (currentPosition != null && contacts.isNotEmpty) { + // Calculate distances for all contacts + final contactsWithDistance = contacts + .where((c) => c.displayLocation != null) + .map((contact) { + final bearing = _calculateBearing( + currentPosition!.latitude, + currentPosition!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + final distance = _calculateDistance( + currentPosition!.latitude, + currentPosition!.longitude, + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ); + return { + 'contact': contact, + 'bearing': bearing, + 'distance': distance, + }; + }) + .toList(); + + if (contactsWithDistance.isEmpty) return; + + // Base distance for zoom level 1.0 (in meters) + // At 1x zoom, contacts within 1km appear inside the compass + final baseDistance = 1000.0 / zoomLevel; + + for (final item in contactsWithDistance) { + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + // Adjust bearing relative to current heading + final relativeBearing = (bearing - heading + 360) % 360; + final angle = relativeBearing * pi / 180 - pi / 2; + + // Calculate normalized distance (0 to 1, where 1 is at the rim) + // Apply zoom level: higher zoom = contacts appear closer + double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); + + // Calculate contact position radius (from center to rim based on distance) + final contactRadius = + radius * normalizedDistance * 0.85; // 0.85 to keep inside rim + + // Position of contact dot + final dotX = center.dx + contactRadius * cos(angle); + final dotY = center.dy + contactRadius * sin(angle); + + // Draw line from center to contact + final linePaint = Paint() + ..color = Colors.lightBlue.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawLine(center, Offset(dotX, dotY), linePaint); + + // Draw contact dot (size varies with zoom) + final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0); + final dotPaint = Paint() + ..color = Colors.lightBlue + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint); + + // Draw darker shade border (same color family) + final borderPaint = Paint() + ..color = Colors.blue.shade800 + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5; + canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint); + + // Draw distance label near the contact (only if not too crowded) + if (zoomLevel >= 0.75) { + final distanceText = _formatDistance(distance); + final labelOffset = dotSize + 12; + final labelX = center.dx + (contactRadius + labelOffset) * cos(angle); + final labelY = center.dy + (contactRadius + labelOffset) * sin(angle); + + textPainter.text = TextSpan( + text: distanceText, + style: const TextStyle( + color: Colors.lightBlue, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + + // Draw background for readability + final bgRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(labelX, labelY), + width: textPainter.width + 4, + height: textPainter.height + 2, + ), + const Radius.circular(3), + ); + final bgPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.9) + ..style = PaintingStyle.fill; + canvas.drawRRect(bgRect, bgPaint); + + textPainter.paint( + canvas, + Offset( + labelX - textPainter.width / 2, + labelY - textPainter.height / 2, + ), + ); + } + } + } + + // Draw SAR markers as colored dots relative to distance, scaled by zoom level + if (currentPosition != null && sarMarkers.isNotEmpty) { + // Calculate distances for all SAR markers + final markersWithDistance = sarMarkers.map((marker) { + final bearing = _calculateBearing( + currentPosition!.latitude, + currentPosition!.longitude, + marker.location.latitude, + marker.location.longitude, + ); + final distance = _calculateDistance( + currentPosition!.latitude, + currentPosition!.longitude, + marker.location.latitude, + marker.location.longitude, + ); + return {'marker': marker, 'bearing': bearing, 'distance': distance}; + }).toList(); + + // Base distance for zoom level 1.0 (in meters) + final baseDistance = 1000.0 / zoomLevel; + + for (final item in markersWithDistance) { + final marker = item['marker'] as SarMarker; + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + // Adjust bearing relative to current heading + final relativeBearing = (bearing - heading + 360) % 360; + final angle = relativeBearing * pi / 180 - pi / 2; + + // Calculate normalized distance (0 to 1, where 1 is at the rim) + double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0); + + // Calculate marker position radius (from center to rim based on distance) + final markerRadius = radius * normalizedDistance * 0.85; + + // Position of marker dot + final dotX = center.dx + markerRadius * cos(angle); + final dotY = center.dy + markerRadius * sin(angle); + + // Determine color based on SAR marker type + Color markerColor; + Color borderColor; + switch (marker.type) { + case SarMarkerType.foundPerson: + markerColor = Colors.green; + borderColor = Colors.green.shade900; + break; + case SarMarkerType.fire: + markerColor = Colors.red; + borderColor = Colors.red.shade900; + break; + case SarMarkerType.stagingArea: + markerColor = Colors.orange; + borderColor = Colors.orange.shade900; + break; + case SarMarkerType.object: + markerColor = Colors.purple; + borderColor = Colors.purple.shade900; + break; + case SarMarkerType.unknown: + markerColor = Colors.grey; + borderColor = Colors.grey.shade900; + break; + } + + // Draw line from center to SAR marker + final linePaint = Paint() + ..color = markerColor.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawLine(center, Offset(dotX, dotY), linePaint); + + // Draw SAR marker dot (slightly larger than contacts) + final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0); + final dotPaint = Paint() + ..color = markerColor + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint); + + // Draw darker shade border (same color family) + final borderPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5; + canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint); + + // Draw distance label near the SAR marker + if (zoomLevel >= 0.75) { + final distanceText = _formatDistance(distance); + final labelOffset = dotSize + 14; + final labelX = center.dx + (markerRadius + labelOffset) * cos(angle); + final labelY = center.dy + (markerRadius + labelOffset) * sin(angle); + + textPainter.text = TextSpan( + text: distanceText, + style: TextStyle( + color: markerColor, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + + // Draw background for readability + final bgRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(labelX, labelY), + width: textPainter.width + 4, + height: textPainter.height + 2, + ), + const Radius.circular(3), + ); + final bgPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.9) + ..style = PaintingStyle.fill; + canvas.drawRRect(bgRect, bgPaint); + + textPainter.paint( + canvas, + Offset( + labelX - textPainter.width / 2, + labelY - textPainter.height / 2, + ), + ); + } + } + } + + // Draw center heading indicator (fixed pointing up) + final indicatorPaint = Paint() + ..color = hasHeading ? Colors.red : Colors.grey + ..style = PaintingStyle.fill; + + final path = ui.Path() + ..moveTo(center.dx, center.dy - 40) + ..lineTo(center.dx - 10, center.dy + 10) + ..lineTo(center.dx + 10, center.dy + 10) + ..close(); + + canvas.drawPath(path, indicatorPaint); + } + + double _calculateBearing(double lat1, double lon1, double lat2, double lon2) { + final dLon = (lon2 - lon1) * pi / 180; + final lat1Rad = lat1 * pi / 180; + final lat2Rad = lat2 * pi / 180; + + final y = sin(dLon) * cos(lat2Rad); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + + final bearing = atan2(y, x) * 180 / pi; + return (bearing + 360) % 360; + } + + double _calculateDistance( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + + final a = + sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +/// Location format toggle widget +class _LocationFormatToggle extends StatefulWidget { + final Position? position; + + const _LocationFormatToggle({required this.position}); + + @override + State<_LocationFormatToggle> createState() => _LocationFormatToggleState(); +} + +class _LocationFormatToggleState extends State<_LocationFormatToggle> { + CoordinateDisplayFormat _format = CoordinateDisplayFormat.decimal; + + @override + Widget build(BuildContext context) { + final position = widget.position; + if (position == null) { + return SizedBox.shrink(); + } + + final l10n = AppLocalizations.of(context)!; + final String displayText; + + if (_format == CoordinateDisplayFormat.decimal) { + displayText = l10n.latLonFormat( + position.latitude.toStringAsFixed(5), + position.longitude.toStringAsFixed(5), + ); + } else { + displayText = formatCoordinates( + position.latitude, + position.longitude, + _format, + ); + } + + return GestureDetector( + onTap: () { + setState(() { + _format = CoordinateDisplayFormat.values[ + (_format.index + 1) % CoordinateDisplayFormat.values.length]; + }); + }, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + displayText, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/map/compass/compass_math.dart b/lib/widgets/map/compass/compass_math.dart new file mode 100644 index 0000000..590dc4a --- /dev/null +++ b/lib/widgets/map/compass/compass_math.dart @@ -0,0 +1,22 @@ +import 'dart:math'; + +/// Rotate the compass rose opposite the device heading so the current heading +/// stays under the fixed indicator at the top of the dial. +double compassRoseRotationRadians(double headingDegrees) { + final normalizedHeading = headingDegrees % 360; + return _normalizeRadians(-normalizedHeading * pi / 180); +} + +/// Convert a compass marker bearing into an on-screen angle for the dial. +double compassDialAngleRadians({ + required double markerDegrees, + required double headingDegrees, +}) { + return _normalizeRadians( + markerDegrees * pi / 180 - + pi / 2 + + compassRoseRotationRadians(headingDegrees), + ); +} + +double _normalizeRadians(double angle) => atan2(sin(angle), cos(angle)); diff --git a/lib/widgets/map/compass/compass_sar_list.dart b/lib/widgets/map/compass/compass_sar_list.dart new file mode 100644 index 0000000..d8a7429 --- /dev/null +++ b/lib/widgets/map/compass/compass_sar_list.dart @@ -0,0 +1,236 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../models/sar_marker.dart'; + +/// SAR marker list section for the compass dialog. +/// Shows all filtered SAR markers sorted by distance with bearing information. +class CompassSarList extends StatelessWidget { + final List sarMarkers; + final Position? position; + final double? heading; + final SarMarker? selectedSarMarker; + final ValueChanged onSarMarkerTap; + + const CompassSarList({ + super.key, + required this.sarMarkers, + required this.position, + this.heading, + required this.selectedSarMarker, + required this.onSarMarkerTap, + }); + + @override + Widget build(BuildContext context) { + if (sarMarkers.isEmpty) { + return const SizedBox.shrink(); + } + + if (position == null) { + return Text(AppLocalizations.of(context)!.locationUnavailable); + } + + // Calculate bearings and distances for SAR markers + final markersWithBearing = sarMarkers.map((marker) { + final bearing = _calculateBearing( + position!.latitude, + position!.longitude, + marker.location.latitude, + marker.location.longitude, + ); + + final distance = _calculateDistance( + position!.latitude, + position!.longitude, + marker.location.latitude, + marker.location.longitude, + ); + + return { + 'marker': marker, + 'bearing': bearing, + 'distance': distance, + }; + }).toList(); + + // Sort by distance + markersWithBearing.sort((a, b) => + (a['distance'] as double).compareTo(b['distance'] as double)); + + final l10n = AppLocalizations.of(context)!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 16, top: 16, bottom: 8), + child: Text( + l10n.sarMarkers, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ...markersWithBearing.map((item) { + final marker = item['marker'] as SarMarker; + final bearing = item['bearing'] as double; + final distance = item['distance'] as double; + + // Determine color and icon based on marker type + Color markerColor; + IconData markerIcon; + switch (marker.type) { + case SarMarkerType.foundPerson: + markerColor = Colors.green; + markerIcon = Icons.person_pin; + break; + case SarMarkerType.fire: + markerColor = Colors.red; + markerIcon = Icons.local_fire_department; + break; + case SarMarkerType.stagingArea: + markerColor = Colors.orange; + markerIcon = Icons.home_work; + break; + case SarMarkerType.object: + markerColor = Colors.purple; + markerIcon = Icons.inventory_2; + break; + case SarMarkerType.unknown: + markerColor = Colors.grey; + markerIcon = Icons.help_outline; + break; + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: selectedSarMarker == marker + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: selectedSarMarker == marker + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + child: ListTile( + dense: true, + leading: Icon( + markerIcon, + color: markerColor, + size: 24, + ), + title: Text(marker.displayName), + subtitle: Text( + '${_bearingToCardinal(bearing)} • ${_formatDistance(distance)} • ${marker.timeAgo}', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${bearing.round()}°', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (heading != null) + Text( + _formatRelativeBearing(bearing, heading!, context), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + onTap: () { + if (selectedSarMarker == marker) { + // Deselect if already selected + onSarMarkerTap(null); + } else { + // Select this marker + onSarMarkerTap(marker); + } + }, + ), + ); + }), + ], + ); + } + + // Calculate bearing between two points (in degrees) + double _calculateBearing( + double lat1, double lon1, double lat2, double lon2) { + final dLon = (lon2 - lon1) * pi / 180; + final lat1Rad = lat1 * pi / 180; + final lat2Rad = lat2 * pi / 180; + + final y = sin(dLon) * cos(lat2Rad); + final x = cos(lat1Rad) * sin(lat2Rad) - + sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + + final bearing = atan2(y, x) * 180 / pi; + return (bearing + 360) % 360; + } + + // Calculate distance between two points (in meters) + double _calculateDistance( + double lat1, double lon1, double lat2, double lon2) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + String _bearingToCardinal(double bearing) { + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + final index = ((bearing + 22.5) / 45).floor() % 8; + return directions[index]; + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } + + String _formatRelativeBearing(double bearing, double heading, BuildContext context) { + final l10n = AppLocalizations.of(context)!; + // Calculate relative bearing (how much to turn from current heading) + double relative = bearing - heading; + + // Normalize to -180 to +180 + while (relative > 180) { + relative -= 360; + } + while (relative < -180) { + relative += 360; + } + + final absRelative = relative.abs().round(); + + if (absRelative < 10) { + return l10n.ahead; + } else if (relative > 0) { + return l10n.degreesRight(absRelative); + } else { + return l10n.degreesLeft(absRelative); + } + } +} diff --git a/lib/widgets/map/compass_widget.dart b/lib/widgets/map/compass_widget.dart new file mode 100644 index 0000000..0dc5607 --- /dev/null +++ b/lib/widgets/map/compass_widget.dart @@ -0,0 +1,106 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'compass/compass_math.dart'; + +class CompassWidget extends StatelessWidget { + final double heading; + final bool hasHeading; + + const CompassWidget({ + super.key, + required this.heading, + required this.hasHeading, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Container( + width: 56, + height: 56, + padding: const EdgeInsets.all(8), + child: Stack( + alignment: Alignment.center, + children: [ + // Rotate the rose opposite the heading under the fixed needle. + Transform.rotate( + angle: compassRoseRotationRadians(heading), + child: CustomPaint( + size: const Size(40, 40), + painter: _CompassRosePainter(), + ), + ), + // Fixed needle pointing up (since map rotates) + Icon( + Icons.navigation, + color: hasHeading ? Colors.red : Colors.grey, + size: 28, + ), + // Heading text + Positioned( + bottom: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + hasHeading ? '${heading.round()}°' : '--', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _CompassRosePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.grey.withValues(alpha: 0.3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2; + + // Draw circle + canvas.drawCircle(center, radius, paint); + + // Draw cardinal direction markers + final textPainter = TextPainter(textDirection: TextDirection.ltr); + + final directions = ['N', 'E', 'S', 'W']; + for (int i = 0; i < 4; i++) { + final angle = i * pi / 2 - pi / 2; // Start from North (top) + final x = center.dx + radius * 0.7 * cos(angle); + final y = center.dy + radius * 0.7 * sin(angle); + + textPainter.text = TextSpan( + text: directions[i], + style: TextStyle( + color: Colors.grey.shade700, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(x - textPainter.width / 2, y - textPainter.height / 2), + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/widgets/map/detailed_compass_dialog.dart b/lib/widgets/map/detailed_compass_dialog.dart new file mode 100644 index 0000000..29c49b8 --- /dev/null +++ b/lib/widgets/map/detailed_compass_dialog.dart @@ -0,0 +1,783 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show MissingPluginException; +import 'package:geolocator/geolocator.dart'; +import 'package:flutter_compass/flutter_compass.dart'; +import 'package:latlong2/latlong.dart'; +import '../../services/compass_support.dart'; +import '../../models/contact.dart'; +import '../../models/sar_marker.dart'; +import '../common/location_display.dart'; +import 'compass/compass_header.dart'; +import 'compass/compass_filters.dart'; +import 'compass/compass_sar_list.dart'; +import 'compass/compass_contact_list.dart'; +import '../../l10n/app_localizations.dart'; + +enum HeadingAccuracySeverity { low, medium, high } + +class HeadingAccuracyInfo { + final bool isAccurate; + final String? warning; + final HeadingAccuracySeverity severity; + + HeadingAccuracyInfo({ + required this.isAccurate, + this.warning, + this.severity = HeadingAccuracySeverity.low, + }); +} + +class DetailedCompassDialog extends StatefulWidget { + final Position? initialPosition; + final double? initialHeading; + final List contacts; + final List sarMarkers; + final Contact? preSelectedContact; + final SarMarker? preSelectedSarMarker; + + const DetailedCompassDialog({ + super.key, + required this.initialPosition, + required this.initialHeading, + required this.contacts, + required this.sarMarkers, + this.preSelectedContact, + this.preSelectedSarMarker, + }); + + @override + State createState() => _DetailedCompassDialogState(); +} + +class _DetailedCompassDialogState extends State { + double? _currentHeading; + double? _compassAccuracy; // Compass accuracy in degrees + Position? _currentPosition; + StreamSubscription? _compassSubscription; + StreamSubscription? _positionSubscription; + double _zoomLevel = + 1.0; // 1.0 = default, 0.5 = zoomed out 2x, 2.0 = zoomed in 2x + double _previousScale = 1.0; // Track previous scale for smoother zooming + static const double _minZoom = 0.25; + static const double _maxZoom = 4.0; + static const double _zoomSensitivity = + 0.5; // Lower = less sensitive (0.5 = half speed) + + // Visibility toggles + bool _showContacts = true; + bool _showRepeaters = false; // Hide repeaters by default + bool _showFoundPerson = true; + bool _showFire = true; + bool _showStagingArea = true; + + // Selected item for isolation + Contact? _selectedContact; + SarMarker? _selectedSarMarker; + + @override + void initState() { + super.initState(); + _currentHeading = widget.initialHeading; + _currentPosition = widget.initialPosition; + _selectedContact = widget.preSelectedContact; + _selectedSarMarker = widget.preSelectedSarMarker; + + // Auto-zoom if item is preselected + if (_selectedContact != null || _selectedSarMarker != null) { + // Use post-frame callback to ensure position is set + WidgetsBinding.instance.addPostFrameCallback((_) { + _autoZoomForSelection(); + }); + } + + // Subscribe to compass updates on supported targets only. + final compassStream = CompassSupport.isAvailable + ? FlutterCompass.events + : null; + if (compassStream != null) { + _compassSubscription = compassStream.listen( + (event) { + if (mounted && event.heading != null) { + setState(() { + _currentHeading = event.heading; + _compassAccuracy = event.accuracy; + }); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!mounted) { + return; + } + + if (error is MissingPluginException) { + debugPrint('Compass plugin is unavailable on this platform'); + return; + } + + debugPrint('Compass dialog stream error: $error'); + }, + ); + } + + // Subscribe to position updates + _positionSubscription = + Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 1, + ), + ).listen((position) { + if (mounted) { + setState(() { + _currentPosition = position; + }); + } + }); + } + + @override + void dispose() { + _compassSubscription?.cancel(); + _positionSubscription?.cancel(); + super.dispose(); + } + + // Get current heading (prefer compass over GPS) + double? get currentHeading { + if (_currentHeading != null) return _currentHeading; + if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) { + return _currentPosition!.heading; + } + return null; + } + + // Check heading accuracy and return warning information + HeadingAccuracyInfo get headingAccuracyInfo { + // Using compass + if (_currentHeading != null) { + if (_compassAccuracy == null) { + return HeadingAccuracyInfo( + isAccurate: false, + warning: 'Compass accuracy unknown', + severity: HeadingAccuracySeverity.low, + ); + } else if (_compassAccuracy! > 30) { + return HeadingAccuracyInfo( + isAccurate: false, + warning: + 'Low compass accuracy (±${_compassAccuracy!.round()}°). Calibrate device.', + severity: HeadingAccuracySeverity.high, + ); + } else if (_compassAccuracy! > 15) { + return HeadingAccuracyInfo( + isAccurate: true, + warning: 'Moderate compass accuracy (±${_compassAccuracy!.round()}°)', + severity: HeadingAccuracySeverity.medium, + ); + } + return HeadingAccuracyInfo(isAccurate: true); + } + + // Using GPS heading + if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) { + final headingAccuracy = _currentPosition!.headingAccuracy; + if (headingAccuracy > 0) { + if (headingAccuracy > 30) { + return HeadingAccuracyInfo( + isAccurate: false, + warning: + 'Low GPS heading accuracy (±${headingAccuracy.round()}°). Move faster or use compass.', + severity: HeadingAccuracySeverity.high, + ); + } else if (headingAccuracy > 15) { + return HeadingAccuracyInfo( + isAccurate: true, + warning: + 'Moderate GPS heading accuracy (±${headingAccuracy.round()}°)', + severity: HeadingAccuracySeverity.medium, + ); + } + return HeadingAccuracyInfo(isAccurate: true); + } + // GPS heading available but no accuracy info + return HeadingAccuracyInfo( + isAccurate: true, + warning: 'Using GPS heading (accuracy unknown)', + severity: HeadingAccuracySeverity.low, + ); + } + + // No heading available + return HeadingAccuracyInfo( + isAccurate: false, + warning: 'No heading available', + severity: HeadingAccuracySeverity.high, + ); + } + + // Filter SAR markers based on visibility settings + List _getFilteredSarMarkers() { + return widget.sarMarkers.where((marker) { + switch (marker.type) { + case SarMarkerType.foundPerson: + return _showFoundPerson; + case SarMarkerType.fire: + return _showFire; + case SarMarkerType.stagingArea: + return _showStagingArea; + case SarMarkerType.object: + return true; // Always show object markers (add filter if needed) + case SarMarkerType.unknown: + return true; // Always show unknown markers + } + }).toList(); + } + + void _handleZoomUpdate(double scale) { + setState(() { + // Calculate scale delta from previous scale + final scaleDelta = scale - _previousScale; + + // Apply sensitivity factor to make it more coarse + final adjustedDelta = scaleDelta * _zoomSensitivity; + + // Apply the delta to current zoom level + _zoomLevel = (_zoomLevel * (1.0 + adjustedDelta)).clamp( + _minZoom, + _maxZoom, + ); + + // Update previous scale + _previousScale = scale; + }); + } + + void _handleScaleStart() { + _previousScale = 1.0; + } + + void _handleScaleEnd() { + _previousScale = 1.0; + } + + // Calculate appropriate zoom level for selected item + void _autoZoomForSelection() { + if (_currentPosition == null) return; + + double? targetDistance; + + if (_selectedContact != null && _selectedContact!.displayLocation != null) { + targetDistance = _calculateDistance( + _currentPosition!.latitude, + _currentPosition!.longitude, + _selectedContact!.displayLocation!.latitude, + _selectedContact!.displayLocation!.longitude, + ); + } else if (_selectedSarMarker != null) { + targetDistance = _calculateDistance( + _currentPosition!.latitude, + _currentPosition!.longitude, + _selectedSarMarker!.location.latitude, + _selectedSarMarker!.location.longitude, + ); + } + + if (targetDistance != null) { + // Calculate zoom level to fit target within 70% of compass radius + // Base distance at 1x zoom is 1000m + // We want target at 70% of radius, so: targetDistance / zoomLevel = 700m + final targetZoom = (targetDistance / 700.0).clamp(_minZoom, _maxZoom); + setState(() { + _zoomLevel = targetZoom; + }); + } + } + + @override + Widget build(BuildContext context) { + final heading = currentHeading; + final position = _currentPosition; + final accuracyInfo = headingAccuracyInfo; + + return Column( + children: [ + // Header with back button + Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + children: [ + Text( + AppLocalizations.of(context)!.compass, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + AppLocalizations.of(context)!.navigationAndContacts, + style: const TextStyle(color: Colors.grey, fontSize: 14), + ), + ], + ), + ), + CompassFilters( + showContacts: _showContacts, + showRepeaters: _showRepeaters, + showFoundPerson: _showFoundPerson, + showFire: _showFire, + showStagingArea: _showStagingArea, + onShowContactsChanged: (value) { + setState(() { + _showContacts = value; + }); + }, + onShowRepeatersChanged: (value) { + setState(() { + _showRepeaters = value; + }); + }, + onShowFoundPersonChanged: (value) { + setState(() { + _showFoundPerson = value; + }); + }, + onShowFireChanged: (value) { + setState(() { + _showFire = value; + }); + }, + onShowStagingAreaChanged: (value) { + setState(() { + _showStagingArea = value; + }); + }, + onShowAll: () { + setState(() { + _showContacts = true; + _showRepeaters = true; + _showFoundPerson = true; + _showFire = true; + _showStagingArea = true; + }); + }, + ), + ], + ), + ), + // Heading accuracy warning banner + if (accuracyInfo.warning != null) + _buildAccuracyWarning(context, accuracyInfo), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Compass header with info and location formats + CompassHeader( + heading: heading, + position: position, + hasHeading: heading != null, + currentPosition: position, + contacts: _selectedContact != null + ? [_selectedContact!] + : (_selectedSarMarker != null + ? [] + : widget.contacts + .where( + (c) => + (_showContacts && + !c.isRepeater && + !c.isRoom) || + (_showRepeaters && c.isRepeater), + ) + .toList()), + sarMarkers: _selectedSarMarker != null + ? [_selectedSarMarker!] + : (_selectedContact != null + ? [] + : _getFilteredSarMarkers()), + zoomLevel: _zoomLevel, + previousScale: _previousScale, + onZoomUpdate: _handleZoomUpdate, + onScaleStart: _handleScaleStart, + onScaleEnd: _handleScaleEnd, + ), + const SizedBox(height: 12), + // Selected item detail view + if (_selectedContact != null || _selectedSarMarker != null) + _buildSelectedItemDetail(context, heading, position), + const SizedBox(height: 12), + // Contacts list + if (widget.contacts.isNotEmpty) + CompassContactList( + contacts: widget.contacts, + position: position, + heading: heading, + selectedContact: _selectedContact, + showContacts: _showContacts, + showRepeaters: _showRepeaters, + onContactTap: (contact) { + setState(() { + _selectedContact = contact; + if (contact != null) { + _selectedSarMarker = null; + } + }); + _autoZoomForSelection(); + }, + ), + // SAR Markers list + if (_getFilteredSarMarkers().isNotEmpty) + CompassSarList( + sarMarkers: _getFilteredSarMarkers(), + position: position, + heading: heading, + selectedSarMarker: _selectedSarMarker, + onSarMarkerTap: (marker) { + setState(() { + _selectedSarMarker = marker; + if (marker != null) { + _selectedContact = null; + } + }); + _autoZoomForSelection(); + }, + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildAccuracyWarning(BuildContext context, HeadingAccuracyInfo info) { + Color backgroundColor; + Color iconColor; + IconData icon; + + switch (info.severity) { + case HeadingAccuracySeverity.high: + backgroundColor = Colors.red.shade100; + iconColor = Colors.red.shade700; + icon = Icons.error_outline; + break; + case HeadingAccuracySeverity.medium: + backgroundColor = Colors.orange.shade100; + iconColor = Colors.orange.shade700; + icon = Icons.warning_amber_outlined; + break; + case HeadingAccuracySeverity.low: + backgroundColor = Colors.blue.shade100; + iconColor = Colors.blue.shade700; + icon = Icons.info_outline; + break; + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: iconColor.withValues(alpha: 0.3), width: 1), + ), + child: Row( + children: [ + Icon(icon, color: iconColor, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + info.warning!, + style: TextStyle( + color: iconColor, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + Widget _buildSelectedItemDetail( + BuildContext context, + double? heading, + Position? position, + ) { + if (position == null) { + return const SizedBox.shrink(); + } + + String title; + IconData icon; + Color color; + double? bearing; + double? distance; + LatLng? targetLocation; + String? additionalInfo; + + if (_selectedContact != null) { + title = _selectedContact!.displayName; + icon = Icons.person; + color = Theme.of(context).colorScheme.primary; + targetLocation = _selectedContact!.displayLocation; + + if (targetLocation != null) { + bearing = _calculateBearing( + position.latitude, + position.longitude, + targetLocation.latitude, + targetLocation.longitude, + ); + distance = _calculateDistance( + position.latitude, + position.longitude, + targetLocation.latitude, + targetLocation.longitude, + ); + } + + // Show voltage/battery if available + if (_selectedContact!.telemetry?.batteryMilliVolts != null) { + final volts = (_selectedContact!.telemetry!.batteryMilliVolts! / 1000) + .toStringAsFixed(3); + final percent = _selectedContact!.telemetry!.batteryPercentage != null + ? ' (${_selectedContact!.telemetry!.batteryPercentage!.round()}%)' + : ''; + additionalInfo = 'Voltage: ${volts}V$percent'; + } else if (_selectedContact!.telemetry?.batteryPercentage != null) { + additionalInfo = + 'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%'; + } + } else if (_selectedSarMarker != null) { + title = _selectedSarMarker!.displayName; + targetLocation = _selectedSarMarker!.location; + additionalInfo = _selectedSarMarker!.timeAgo; + + switch (_selectedSarMarker!.type) { + case SarMarkerType.foundPerson: + icon = Icons.person_pin; + color = Colors.green; + break; + case SarMarkerType.fire: + icon = Icons.local_fire_department; + color = Colors.red; + break; + case SarMarkerType.stagingArea: + icon = Icons.home_work; + color = Colors.orange; + break; + case SarMarkerType.object: + icon = Icons.inventory_2; + color = Colors.purple; + break; + case SarMarkerType.unknown: + icon = Icons.help_outline; + color = Colors.grey; + break; + } + + bearing = _calculateBearing( + position.latitude, + position.longitude, + targetLocation.latitude, + targetLocation.longitude, + ); + distance = _calculateDistance( + position.latitude, + position.longitude, + targetLocation.latitude, + targetLocation.longitude, + ); + } else { + return const SizedBox.shrink(); + } + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16), + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + // Header with icon and title + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: + _selectedContact != null && + _selectedContact!.roleEmoji != null + ? Text( + _selectedContact!.roleEmoji!, + style: const TextStyle(fontSize: 24), + ) + : Icon(icon, size: 24, color: color), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (additionalInfo != null) + Text( + additionalInfo, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.grey), + ), + ], + ), + ), + // Close button to deselect contact + IconButton( + icon: const Icon(Icons.close, size: 20), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + setState(() { + _selectedContact = null; + _selectedSarMarker = null; + }); + }, + ), + ], + ), + if (bearing != null && distance != null) ...[ + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + // Distance and bearing info + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildLargeInfoCard( + context, + AppLocalizations.of(context)!.distance, + _formatDistance(distance), + Icons.straighten, + color, + ), + _buildLargeInfoCard( + context, + AppLocalizations.of(context)!.bearing, + '${bearing.round()}°', + Icons.navigation, + color, + ), + _buildLargeInfoCard( + context, + AppLocalizations.of(context)!.direction, + _bearingToCardinal(bearing), + Icons.explore, + color, + ), + ], + ), + const SizedBox(height: 8), + // Coordinates with modal + if (targetLocation != null) + LocationDisplay(location: targetLocation), + ], + ], + ), + ), + ); + } + + Widget _buildLargeInfoCard( + BuildContext context, + String label, + String value, + IconData icon, + Color color, + ) { + return Column( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(height: 4), + Text( + value, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text(label, style: Theme.of(context).textTheme.labelSmall), + ], + ); + } + + // Calculate bearing between two points (in degrees) + double _calculateBearing(double lat1, double lon1, double lat2, double lon2) { + final dLon = (lon2 - lon1) * pi / 180; + final lat1Rad = lat1 * pi / 180; + final lat2Rad = lat2 * pi / 180; + + final y = sin(dLon) * cos(lat2Rad); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + + final bearing = atan2(y, x) * 180 / pi; + return (bearing + 360) % 360; + } + + // Calculate distance between two points (in meters) + double _calculateDistance( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const R = 6371000; // Earth's radius in meters + final dLat = (lat2 - lat1) * pi / 180; + final dLon = (lon2 - lon1) * pi / 180; + + final a = + sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * pi / 180) * + cos(lat2 * pi / 180) * + sin(dLon / 2) * + sin(dLon / 2); + + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + String _bearingToCardinal(double bearing) { + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + final index = ((bearing + 22.5) / 45).floor() % 8; + return directions[index]; + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.round()}m'; + } else { + return '${(meters / 1000).toStringAsFixed(1)}km'; + } + } +} diff --git a/lib/widgets/map/drawing_layer.dart b/lib/widgets/map/drawing_layer.dart new file mode 100644 index 0000000..c5058fd --- /dev/null +++ b/lib/widgets/map/drawing_layer.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import '../../models/map_drawing.dart'; +import '../../l10n/app_localizations.dart'; + +/// Widget that renders map drawings as polylines +class DrawingLayer extends StatelessWidget { + final List drawings; + final MapDrawing? previewDrawing; + final LatLng Function(LatLng point)? pointTransformer; + + const DrawingLayer({ + super.key, + required this.drawings, + this.previewDrawing, + this.pointTransformer, + }); + + @override + Widget build(BuildContext context) { + final List polylines = []; + + // Add completed drawings + for (final drawing in drawings) { + polylines.add(_createPolyline(drawing, isPreview: false)); + } + + // Add preview drawing (if any) + if (previewDrawing != null) { + polylines.add(_createPolyline(previewDrawing!, isPreview: true)); + } + + return PolylineLayer(polylines: polylines); + } + + /// Create a polyline from a drawing + Polyline _createPolyline(MapDrawing drawing, {required bool isPreview}) { + final points = _getPoints(drawing); + + // Different styles for different drawing sources + final double opacity; + final double strokeWidth; + + if (isPreview) { + // Preview drawing (currently being drawn) + opacity = 0.6; + strokeWidth = 4.0; + } else if (drawing.isReceived) { + // Received drawing from another node + opacity = 1.0; + strokeWidth = 3.0; + } else { + // Local drawing (solid line, normal thickness) + opacity = 1.0; + strokeWidth = 4.0; + } + + return Polyline( + points: points, + color: drawing.color.withValues(alpha: opacity), + strokeWidth: strokeWidth, + borderColor: Colors.white.withValues(alpha: opacity * 0.8), + borderStrokeWidth: 1.0, + // Use dotted pattern for received drawings + pattern: drawing.isReceived && !isPreview + ? StrokePattern.dotted(spacingFactor: 2) + : const StrokePattern.solid(), + ); + } + + /// Get points from a drawing based on its type + List _getPoints(MapDrawing drawing) { + if (drawing is LineDrawing) { + return drawing.points.map(_transformPoint).toList(); + } else if (drawing is RectangleDrawing) { + return drawing.corners.map(_transformPoint).toList(); + } + return []; + } + + LatLng _transformPoint(LatLng point) { + return pointTransformer?.call(point) ?? point; + } +} + +/// Widget that shows drawing markers (start/end points) +class DrawingMarkersLayer extends StatelessWidget { + final List drawings; + final Function(String drawingId)? onDeleteDrawing; + final Function(MapDrawing drawing)? onTapDrawing; + final bool showDeleteButtons; + final LatLng Function(LatLng point)? pointTransformer; + + const DrawingMarkersLayer({ + super.key, + required this.drawings, + this.onDeleteDrawing, + this.onTapDrawing, + this.showDeleteButtons = false, + this.pointTransformer, + }); + + @override + Widget build(BuildContext context) { + final List markers = []; + + // Add markers for each drawing + for (final drawing in drawings) { + final centerPoint = _getCenterPoint(drawing); + if (centerPoint != null) { + if (showDeleteButtons) { + // Show delete button when in drawing mode + markers.add( + Marker( + point: centerPoint, + width: 40, + height: 40, + child: GestureDetector( + onTap: () { + if (onDeleteDrawing != null) { + _showDeleteDialog(context, drawing); + } + }, + child: Container( + decoration: BoxDecoration( + color: drawing.color.withValues(alpha: 0.9), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon(Icons.close, color: Colors.white, size: 20), + ), + ), + ), + ); + } + } + } + + if (markers.isEmpty) { + return const SizedBox.shrink(); + } + + return MarkerLayer(markers: markers); + } + + /// Get the center point of a drawing + LatLng? _getCenterPoint(MapDrawing drawing) { + if (drawing is LineDrawing && drawing.points.isNotEmpty) { + // Use the middle point of the line + final midIndex = drawing.points.length ~/ 2; + return _transformPoint(drawing.points[midIndex]); + } else if (drawing is RectangleDrawing) { + // Use the center of the rectangle + return _transformPoint( + LatLng( + (drawing.topLeft.latitude + drawing.bottomRight.latitude) / 2, + (drawing.topLeft.longitude + drawing.bottomRight.longitude) / 2, + ), + ); + } + return null; + } + + LatLng _transformPoint(LatLng point) { + return pointTransformer?.call(point) ?? point; + } + + /// Show delete confirmation dialog + void _showDeleteDialog(BuildContext context, MapDrawing drawing) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.deleteDrawing), + content: Text(AppLocalizations.of(context)!.deleteThisDrawing(drawing.type.name)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + onDeleteDrawing?.call(drawing.id); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.delete), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/map/drawing_toolbar.dart b/lib/widgets/map/drawing_toolbar.dart new file mode 100644 index 0000000..c0ac827 --- /dev/null +++ b/lib/widgets/map/drawing_toolbar.dart @@ -0,0 +1,1151 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/drawing_provider.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; +import '../../providers/messages_provider.dart'; +import '../../models/map_drawing.dart'; +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../../l10n/app_localizations.dart'; +import '../../utils/toast_logger.dart'; +import '../drawing_minimap_preview.dart'; + +/// Toolbar for drawing controls on the map +class DrawingToolbar extends StatelessWidget { + const DrawingToolbar({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, drawingProvider, _) { + if (!drawingProvider.isDrawing) { + // Show compact floating button when not in drawing mode + return FloatingActionButton.small( + heroTag: 'drawing_tool', + onPressed: () => _showDrawingMenu(context, drawingProvider), + child: const Icon(Icons.edit), + ); + } + + // Show full toolbar when in drawing mode + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Title bar with close button + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getIconForMode(drawingProvider.drawingMode), + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + _getTitleForMode(drawingProvider.drawingMode, context), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => drawingProvider.exitDrawingMode(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, + ), + ), + ], + ), + const SizedBox(height: 8), + // Color picker - more compact + Wrap( + spacing: 0, + runSpacing: 0, + children: DrawingColors.palette.map((color) { + final isSelected = drawingProvider.selectedColor == color; + // Padding expands the touch target to 40x40 while keeping + // the swatch visually compact (glove-friendly hit area) + return GestureDetector( + onTap: () => drawingProvider.setColor(color), + behavior: HitTestBehavior.opaque, + child: Container( + margin: const EdgeInsets.all(8), + width: 24, + height: 24, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? Colors.white + : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + boxShadow: [ + if (isSelected) + BoxShadow( + color: color.withValues(alpha: 0.5), + blurRadius: 4, + spreadRadius: 1, + ), + ], + ), + child: isSelected + ? const Icon( + Icons.check, + color: Colors.white, + size: 12, + ) + : null, + ), + ); + }).toList(), + ), + const SizedBox(height: 8), + // Action buttons + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Cancel current drawing + if (drawingProvider.currentLinePoints.isNotEmpty || + drawingProvider.rectangleStartPoint != null) + IconButton( + icon: const Icon(Icons.undo), + onPressed: () => drawingProvider.cancelCurrentDrawing(), + tooltip: AppLocalizations.of(context)!.cancel, + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, + ), + ), + // Clear measurement + if (drawingProvider.drawingMode == DrawingMode.measure && + drawingProvider.measurementPoint1 != null) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () => drawingProvider.clearMeasurement(), + tooltip: AppLocalizations.of(context)!.clearMeasurement, + color: Colors.orange, + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, + ), + ), + // Complete line drawing + if (drawingProvider.drawingMode == DrawingMode.line && + drawingProvider.currentLinePoints.length >= 2) + IconButton( + icon: const Icon(Icons.check), + onPressed: () => drawingProvider.completeLine(), + tooltip: AppLocalizations.of(context)!.completeLine, + color: Colors.green, + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, + ), + ), + // Clear all drawings + if (drawingProvider.drawings.isNotEmpty) + IconButton( + icon: const Icon(Icons.delete_sweep), + onPressed: () => + _showClearAllDialog(context, drawingProvider), + tooltip: AppLocalizations.of(context)!.clearAll, + color: Colors.red, + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, + ), + ), + ], + ), + ], + ), + ); + }, + ); + } + + /// Show drawing mode selection menu + void _showDrawingMenu(BuildContext context, DrawingProvider drawingProvider) { + // Capture root context for use after modal closes + final rootContext = context; + + showModalBottomSheet( + context: context, + builder: (sheetContext) => SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const Icon(Icons.edit), + const SizedBox(width: 12), + Text( + AppLocalizations.of(sheetContext)!.drawingTools, + style: Theme.of(sheetContext).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.show_chart), + title: Text(AppLocalizations.of(sheetContext)!.drawLine), + subtitle: Text(AppLocalizations.of(sheetContext)!.drawLineDesc), + onTap: () { + Navigator.pop(sheetContext); + drawingProvider.setDrawingMode(DrawingMode.line); + }, + ), + ListTile( + leading: const Icon(Icons.crop_square), + title: Text(AppLocalizations.of(sheetContext)!.drawRectangle), + subtitle: Text( + AppLocalizations.of(sheetContext)!.drawRectangleDesc, + ), + onTap: () { + Navigator.pop(sheetContext); + drawingProvider.setDrawingMode(DrawingMode.rectangle); + }, + ), + ListTile( + leading: const Icon(Icons.straighten), + title: Text(AppLocalizations.of(sheetContext)!.measureDistance), + subtitle: Text( + AppLocalizations.of(sheetContext)!.measureDistanceDesc, + ), + onTap: () { + Navigator.pop(sheetContext); + drawingProvider.setDrawingMode(DrawingMode.measure); + }, + ), + const Divider(), + // Toggle received drawings visibility + SwitchListTile( + secondary: Icon( + drawingProvider.showReceivedDrawings + ? Icons.visibility + : Icons.visibility_off, + ), + title: Text( + AppLocalizations.of(sheetContext)!.showReceivedDrawings, + ), + subtitle: Text( + drawingProvider.showReceivedDrawings + ? AppLocalizations.of(sheetContext)!.showingAllDrawings + : AppLocalizations.of( + sheetContext, + )!.showingOnlyYourDrawings, + ), + value: drawingProvider.showReceivedDrawings, + onChanged: (value) { + drawingProvider.toggleReceivedDrawings(); + }, + ), + // Toggle SAR markers visibility + SwitchListTile( + secondary: Icon( + drawingProvider.showSarMarkers + ? Icons.pin_drop + : Icons.pin_drop_outlined, + ), + title: Text(AppLocalizations.of(sheetContext)!.showSarMarkers), + subtitle: Text( + drawingProvider.showSarMarkers + ? AppLocalizations.of(sheetContext)!.showingSarMarkers + : AppLocalizations.of(sheetContext)!.hidingSarMarkers, + ), + value: drawingProvider.showSarMarkers, + onChanged: (value) { + drawingProvider.toggleSarMarkers(); + }, + ), + // Drawings list with individual actions + if (drawingProvider.drawings.isNotEmpty) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const Icon(Icons.layers, size: 20), + const SizedBox(width: 8), + Text( + AppLocalizations.of( + sheetContext, + )!.yourDrawingsCount(drawingProvider.drawings.length), + style: Theme.of(sheetContext).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + ), + ...drawingProvider.drawings.map((drawing) { + final isShared = drawing.isShared; + final typeStr = drawing is LineDrawing + ? AppLocalizations.of(sheetContext)!.line + : AppLocalizations.of(sheetContext)!.rectangle; + final colorName = DrawingColors.colorToName(drawing.color); + + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300, width: 1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + // Minimap preview + DrawingMinimapPreview(drawing: drawing), + const SizedBox(width: 12), + // Drawing info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + typeStr, + style: Theme.of(sheetContext) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: drawing.color, + shape: BoxShape.circle, + border: Border.all( + color: Colors.black26, + width: 1, + ), + ), + ), + const SizedBox(width: 6), + Text( + colorName, + style: Theme.of( + sheetContext, + ).textTheme.labelSmall, + ), + if (isShared) ...[ + const SizedBox(width: 8), + Icon( + Icons.check_circle, + size: 14, + color: Colors.green.shade700, + ), + const SizedBox(width: 4), + Text( + AppLocalizations.of(sheetContext)!.shared, + style: Theme.of(sheetContext) + .textTheme + .labelSmall + ?.copyWith( + color: Colors.green.shade700, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ], + ), + ), + // Action buttons + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Share button + if (!isShared) + IconButton( + icon: const Icon(Icons.share, size: 20), + onPressed: () async { + Navigator.pop(sheetContext); + await Future.delayed( + const Duration(milliseconds: 100), + ); + if (rootContext.mounted) { + _showShareSingleDrawingDialog( + rootContext, + drawingProvider, + drawing, + ); + } + }, + tooltip: AppLocalizations.of(context)!.share, + color: Colors.blue, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + ), + const SizedBox(width: 4), + // Delete button + IconButton( + icon: const Icon(Icons.delete, size: 20), + onPressed: () { + Navigator.pop(sheetContext); + _showDeleteSingleDrawingDialog( + rootContext, + drawingProvider, + drawing, + ); + }, + tooltip: AppLocalizations.of(context)!.delete, + color: Colors.red, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + ), + ], + ), + ], + ), + ); + }), + const Divider(), + ListTile( + leading: const Icon(Icons.share, color: Colors.blue), + title: Text(AppLocalizations.of(sheetContext)!.shareDrawings), + subtitle: Text( + AppLocalizations.of(sheetContext)!.broadcastDrawingsToTeam( + drawingProvider.drawings.length, + drawingProvider.drawings.length > 1 ? 's' : '', + ), + ), + onTap: () async { + Navigator.pop(sheetContext); + // Small delay to ensure first bottom sheet is fully closed + // before opening the second one + await Future.delayed(const Duration(milliseconds: 100)); + if (rootContext.mounted) { + _showShareDrawingsDialog(rootContext, drawingProvider); + } + }, + ), + ListTile( + leading: const Icon(Icons.delete_sweep, color: Colors.red), + title: Text( + AppLocalizations.of(sheetContext)!.clearAllDrawings, + ), + subtitle: Text( + AppLocalizations.of(sheetContext)!.removeAllDrawings( + drawingProvider.drawings.length, + drawingProvider.drawings.length > 1 ? 's' : '', + ), + ), + onTap: () { + Navigator.pop(sheetContext); + _showClearAllDialog(rootContext, drawingProvider); + }, + ), + ], + ], + ), + ), + ), + ); + } + + /// Show clear all confirmation dialog + void _showClearAllDialog( + BuildContext context, + DrawingProvider drawingProvider, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.clearAllDrawings), + content: Text( + AppLocalizations.of(context)!.deleteAllDrawingsConfirm( + drawingProvider.drawings.length, + drawingProvider.drawings.length > 1 ? 's' : '', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + drawingProvider.clearAllDrawings(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.clearAll), + ), + ], + ), + ); + } + + /// Get icon for drawing mode + IconData _getIconForMode(DrawingMode mode) { + switch (mode) { + case DrawingMode.line: + return Icons.show_chart; + case DrawingMode.rectangle: + return Icons.crop_square; + case DrawingMode.measure: + return Icons.straighten; + case DrawingMode.none: + return Icons.edit; + } + } + + /// Get title for drawing mode + String _getTitleForMode(DrawingMode mode, BuildContext context) { + switch (mode) { + case DrawingMode.line: + return AppLocalizations.of(context)!.drawLine; + case DrawingMode.rectangle: + return AppLocalizations.of(context)!.drawRectangle; + case DrawingMode.measure: + return AppLocalizations.of(context)!.measureDistance; + case DrawingMode.none: + return AppLocalizations.of(context)!.drawing; + } + } + + /// Show share drawings dialog + void _showShareDrawingsDialog( + BuildContext context, + DrawingProvider drawingProvider, + ) { + debugPrint('🎨 [DrawingToolbar] _showShareDrawingsDialog called'); + + // Read providers BEFORE any async operations or dialogs + // This ensures we have the correct BuildContext + final connectionProvider = Provider.of( + context, + listen: false, + ); + final contactsProvider = Provider.of( + context, + listen: false, + ); + + debugPrint( + ' Connection status: ${connectionProvider.deviceInfo.isConnected}', + ); + + if (!connectionProvider.deviceInfo.isConnected) { + debugPrint(' ❌ Not connected - showing error toast'); + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.notConnectedToDevice, + ); + } + return; + } + + // Get device name for sender identification + final senderName = connectionProvider.deviceInfo.selfName ?? 'Unknown'; + + // Filter drawings - only share unshared local drawings + final unsharedDrawings = drawingProvider.getUnsharedDrawings(); + + debugPrint(' Unshared drawings count: ${unsharedDrawings.length}'); + debugPrint(' Total drawings count: ${drawingProvider.drawings.length}'); + + if (unsharedDrawings.isEmpty) { + debugPrint(' ℹ️ No unshared drawings - showing info toast'); + if (context.mounted) { + ToastLogger.info(context, 'All drawings have already been shared'); + } + return; + } + + // Get available rooms + final rooms = contactsProvider.rooms; + debugPrint(' Available rooms: ${rooms.length}'); + + debugPrint(' Showing modal bottom sheet...'); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const Icon(Icons.share), + SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of(context)!.shareDrawingsCount( + unsharedDrawings.length, + unsharedDrawings.length > 1 ? 's' : '', + ), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + const Divider(), + // Option: Send to Public Channel + ListTile( + leading: Icon(Icons.public, color: Colors.blue), + title: Text(AppLocalizations.of(context)!.publicChannel), + subtitle: Text(AppLocalizations.of(context)!.broadcastToAll), + onTap: () async { + debugPrint('📤 [DrawingToolbar] Public Channel tapped'); + // Share BEFORE popping the navigator + await _shareDrawingsToChannel( + sheetContext, + unsharedDrawings, + connectionProvider, + senderName, + ); + if (sheetContext.mounted) { + Navigator.pop(sheetContext); + } + }, + ), + // Option: Send to Room + if (rooms.isNotEmpty) ...[ + ...rooms.map( + (room) => ListTile( + leading: const Icon( + Icons.meeting_room, + color: Colors.green, + ), + title: Text(room.advName), + subtitle: Text( + AppLocalizations.of(context)!.storedPermanently, + ), + onTap: () async { + debugPrint( + '📤 [DrawingToolbar] Room ${room.advName} tapped', + ); + // Share BEFORE popping the navigator + await _shareDrawingsToRoom( + sheetContext, + unsharedDrawings, + connectionProvider, + senderName, + room, + ); + if (sheetContext.mounted) { + Navigator.pop(sheetContext); + } + }, + ), + ), + ], + ], + ), + ), + ), + ); + } + + /// Share drawings to public channel + Future _shareDrawingsToChannel( + BuildContext context, + List drawings, + ConnectionProvider connectionProvider, + String senderName, + ) async { + debugPrint('📤 [DrawingToolbar] _shareDrawingsToChannel called'); + debugPrint(' Drawings to share: ${drawings.length}'); + debugPrint(' Sender name: $senderName'); + debugPrint(' Context mounted: ${context.mounted}'); + + if (!context.mounted) { + debugPrint('❌ Context not mounted, aborting'); + return; + } + + final drawingProvider = Provider.of( + context, + listen: false, + ); + final messagesProvider = Provider.of( + context, + listen: false, + ); + int successCount = 0; + int alreadyShared = 0; + int failedCount = 0; + + for (final drawing in drawings) { + // Skip if already shared + if (drawing.isShared) { + alreadyShared++; + continue; + } + + try { + debugPrint(' Creating message for drawing ${drawing.id}...'); + // Sender name is no longer included in JSON - will be extracted from packet metadata + final message = drawingProvider.createDrawingBroadcastMessage(drawing); + debugPrint( + ' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...', + ); + + // Create message ID and timestamp + final messageId = + '${DateTime.now().millisecondsSinceEpoch}_channel_drawing_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object + final sentMessage = Message( + id: messageId, + messageType: MessageType.channel, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: message, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + channelIdx: 0, + isDrawing: true, + drawingId: drawing.id, + ); + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage); + + debugPrint(' Sending to channel 0...'); + await connectionProvider.sendChannelMessage( + channelIdx: 0, + text: message, + messageId: messageId, + ); + debugPrint(' ✅ Sent successfully'); + + // Mark as shared after successful send + drawingProvider.markDrawingAsShared(drawing.id); + successCount++; + + // Small delay between messages to avoid overwhelming the device + await Future.delayed(const Duration(milliseconds: 200)); + } catch (e, stackTrace) { + debugPrint('❌ Failed to share drawing ${drawing.id}: $e'); + debugPrint(' Stack trace: $stackTrace'); + failedCount++; + } + } + + debugPrint( + ' Share complete: $successCount/${drawings.length} sent, $alreadyShared already shared', + ); + + if (!context.mounted) return; + if (failedCount > 0) { + ToastLogger.error( + context, + 'Failed to share $failedCount drawing${failedCount == 1 ? '' : 's'}', + ); + } else if (successCount > 0) { + ToastLogger.success( + context, + 'Shared $successCount drawing${successCount == 1 ? '' : 's'}', + ); + } + } + + /// Share drawings to a specific room + Future _shareDrawingsToRoom( + BuildContext context, + List drawings, + ConnectionProvider connectionProvider, + String senderName, + Contact room, + ) async { + debugPrint('📤 [DrawingToolbar] _shareDrawingsToRoom called'); + debugPrint(' Room: ${room.advName}'); + debugPrint(' Drawings to share: ${drawings.length}'); + debugPrint(' Sender name: $senderName'); + debugPrint(' Context mounted: ${context.mounted}'); + + if (!context.mounted) { + debugPrint('❌ Context not mounted, aborting'); + return; + } + + final drawingProvider = Provider.of( + context, + listen: false, + ); + final messagesProvider = Provider.of( + context, + listen: false, + ); + int successCount = 0; + int alreadyShared = 0; + int failedCount = 0; + + for (final drawing in drawings) { + // Skip if already shared + if (drawing.isShared) { + alreadyShared++; + continue; + } + + try { + debugPrint(' Creating message for drawing ${drawing.id}...'); + // Sender name is no longer included in JSON - will be extracted from packet metadata + final message = drawingProvider.createDrawingBroadcastMessage(drawing); + debugPrint( + ' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...', + ); + + // Create message ID and timestamp + final messageId = + '${DateTime.now().millisecondsSinceEpoch}_contact_drawing_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object + final sentMessage = Message( + id: messageId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: message, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: room.publicKey, + isDrawing: true, + drawingId: drawing.id, + ); + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage); + + debugPrint(' Sending to room ${room.advName}...'); + await connectionProvider.sendTextMessage( + contactPublicKey: room.publicKey, + text: message, + messageId: messageId, + contact: room, + ); + debugPrint(' ✅ Sent successfully'); + + // Mark as shared after successful send + drawingProvider.markDrawingAsShared(drawing.id); + successCount++; + + // Small delay between messages to avoid overwhelming the device + await Future.delayed(const Duration(milliseconds: 200)); + } catch (e, stackTrace) { + debugPrint( + '❌ Failed to share drawing ${drawing.id} to ${room.advName}: $e', + ); + debugPrint(' Stack trace: $stackTrace'); + failedCount++; + } + } + + debugPrint( + ' Share complete: $successCount/${drawings.length} sent, $alreadyShared already shared', + ); + + if (!context.mounted) return; + if (failedCount > 0) { + ToastLogger.error( + context, + 'Failed to share $failedCount drawing${failedCount == 1 ? '' : 's'}', + ); + } else if (successCount > 0) { + ToastLogger.success( + context, + 'Shared $successCount drawing${successCount == 1 ? '' : 's'}', + ); + } + } + + /// Show share dialog for a single drawing + void _showShareSingleDrawingDialog( + BuildContext context, + DrawingProvider drawingProvider, + MapDrawing drawing, + ) { + debugPrint( + '🎨 [DrawingToolbar] _showShareSingleDrawingDialog called for ${drawing.id}', + ); + + // Read providers BEFORE any async operations or dialogs + final connectionProvider = Provider.of( + context, + listen: false, + ); + final contactsProvider = Provider.of( + context, + listen: false, + ); + + if (!connectionProvider.deviceInfo.isConnected) { + debugPrint(' ❌ Not connected - showing error toast'); + if (context.mounted) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.notConnectedToDevice, + ); + } + return; + } + + // Get device name for sender identification + final senderName = connectionProvider.deviceInfo.selfName ?? 'Unknown'; + + // Get available rooms + final rooms = contactsProvider.rooms; + debugPrint(' Available rooms: ${rooms.length}'); + + // Capture root context for provider access after modal closes + final rootContext = context; + + debugPrint(' Showing modal bottom sheet...'); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const Icon(Icons.share), + const SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of(sheetContext)!.shareDrawing, + style: Theme.of(sheetContext).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + const Divider(), + // Option: Send to Public Channel + ListTile( + leading: const Icon(Icons.public, color: Colors.blue), + title: Text(AppLocalizations.of(sheetContext)!.publicChannel), + subtitle: Text( + AppLocalizations.of(sheetContext)!.shareWithAllNearbyDevices, + ), + onTap: () async { + Navigator.pop(sheetContext); + await _shareDrawingsToChannel( + rootContext, + [drawing], + connectionProvider, + senderName, + ); + }, + ), + // Option: Send to specific room + if (rooms.isNotEmpty) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + AppLocalizations.of(sheetContext)!.shareToRoom, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + ...rooms.map((room) { + return ListTile( + leading: const Icon( + Icons.meeting_room, + color: Colors.orange, + ), + title: Text(room.advName), + subtitle: Text( + AppLocalizations.of( + sheetContext, + )!.sendToPersistentStorage, + ), + onTap: () async { + Navigator.pop(sheetContext); + await _shareDrawingsToRoom( + rootContext, + [drawing], + connectionProvider, + senderName, + room, + ); + }, + ); + }), + ], + ], + ), + ), + ), + ); + } + + /// Show delete confirmation dialog for a single drawing + void _showDeleteSingleDrawingDialog( + BuildContext context, + DrawingProvider drawingProvider, + MapDrawing drawing, + ) { + final typeStr = drawing is LineDrawing + ? AppLocalizations.of(context)!.line + : AppLocalizations.of(context)!.rectangle; + final colorName = DrawingColors.colorToName(drawing.color); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(AppLocalizations.of(dialogContext)!.deleteDrawing), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AppLocalizations.of(dialogContext)!.deleteDrawingConfirm), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + DrawingMinimapPreview(drawing: drawing), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + typeStr, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: drawing.color, + shape: BoxShape.circle, + border: Border.all( + color: Colors.black26, + width: 1, + ), + ), + ), + const SizedBox(width: 6), + Text( + colorName, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + drawingProvider.removeDrawing(drawing.id); + ToastLogger.success( + context, + AppLocalizations.of(context)!.drawingDeleted, + ); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(dialogContext)!.delete), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/map/location_pointer.dart b/lib/widgets/map/location_pointer.dart new file mode 100644 index 0000000..3d56fa4 --- /dev/null +++ b/lib/widgets/map/location_pointer.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; + +/// A navigation arrow pointer that indicates the user's location and direction of travel. +/// +/// The pointer consists of: +/// - An outer semi-transparent circle representing GPS accuracy +/// - An inner triangular arrow pointing in the direction of travel/heading +/// - Optional rotation based on compass or GPS heading +class LocationPointer extends StatelessWidget { + /// The heading in degrees (0-360, where 0 = North, 90 = East) + /// If null or -1, the pointer will not rotate + final double? heading; + + /// The primary color for the pointer + final Color color; + + /// The size of the entire widget + final double size; + + const LocationPointer({ + super.key, + this.heading, + required this.color, + this.size = 40.0, + }); + + @override + Widget build(BuildContext context) { + // Determine if we have valid heading data + final hasValidHeading = heading != null && heading! >= 0; + + // Calculate rotation angle (convert heading to radians) + final rotationAngle = hasValidHeading ? (heading! * 3.14159 / 180.0) : 0.0; + + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + // Outer accuracy circle (very subtle, uses theme color) + Container( + width: size * 0.6, + height: size * 0.6, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + ), + // Inner rotatable arrow pointer (much larger - 90% of size) + Transform.rotate( + angle: rotationAngle, + child: CustomPaint( + size: Size(size * 0.9, size * 0.9), + painter: _NavigationPointerPainter( + color: color, + hasValidHeading: hasValidHeading, + ), + ), + ), + ], + ), + ); + } +} + +/// Custom painter that draws a navigation arrow pointer +class _NavigationPointerPainter extends CustomPainter { + final Color color; + final bool hasValidHeading; + + _NavigationPointerPainter({ + required this.color, + required this.hasValidHeading, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final width = size.width; + final height = size.height; + + if (hasValidHeading) { + // Create navigation arrow with V-shaped cutout at bottom + final arrowPath = Path(); + + // Top point (sharp tip) + arrowPath.moveTo(center.dx, height * 0.08); + + // Right side down to bottom right + arrowPath.lineTo(center.dx + width * 0.42, height * 0.92); + + // V-cutout at bottom - right side to center + arrowPath.lineTo(center.dx, height * 0.70); + + // V-cutout - center to left side + arrowPath.lineTo(center.dx - width * 0.42, height * 0.92); + + // Left side back up to top + arrowPath.lineTo(center.dx, height * 0.08); + + arrowPath.close(); + + // Draw shadow for depth + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.25) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); + + canvas.save(); + canvas.translate(2, 2); + canvas.drawPath(arrowPath, shadowPaint); + canvas.restore(); + + // Left side (lighter - 70% opacity of theme color) + final leftSidePath = Path(); + leftSidePath.moveTo(center.dx, height * 0.08); + leftSidePath.lineTo(center.dx - width * 0.42, height * 0.92); + leftSidePath.lineTo(center.dx, height * 0.70); + leftSidePath.close(); + + final leftPaint = Paint() + ..color = color.withValues(alpha: 0.7) + ..style = PaintingStyle.fill; + canvas.drawPath(leftSidePath, leftPaint); + + // Right side (darker - full theme color) + final rightSidePath = Path(); + rightSidePath.moveTo(center.dx, height * 0.08); + rightSidePath.lineTo(center.dx, height * 0.70); + rightSidePath.lineTo(center.dx + width * 0.42, height * 0.92); + rightSidePath.close(); + + final rightPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + canvas.drawPath(rightSidePath, rightPaint); + + // Optional: Draw white border for contrast + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..strokeJoin = StrokeJoin.round; + canvas.drawPath(arrowPath, borderPaint); + + } else { + // No heading available - draw a circle with white border (uses theme color) + final circlePaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + canvas.drawCircle(center, width * 0.4, circlePaint); + + // White border + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5; + canvas.drawCircle(center, width * 0.4, borderPaint); + + // Center white dot + final centerDot = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + canvas.drawCircle(center, width * 0.15, centerDot); + } + } + + @override + bool shouldRepaint(_NavigationPointerPainter oldDelegate) { + return oldDelegate.color != color || + oldDelegate.hasValidHeading != hasValidHeading; + } +} diff --git a/lib/widgets/map/location_trail_layer.dart b/lib/widgets/map/location_trail_layer.dart new file mode 100644 index 0000000..b4b9952 --- /dev/null +++ b/lib/widgets/map/location_trail_layer.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:provider/provider.dart'; +import '../../providers/map_provider.dart'; + +/// Widget that renders the user's location trail on the map +class LocationTrailLayer extends StatelessWidget { + const LocationTrailLayer({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, mapProvider, child) { + final trail = mapProvider.currentTrail; + final isVisible = mapProvider.isTrailVisible; + + // Don't render if trail is hidden or empty + if (!isVisible || trail == null || trail.points.length < 2) { + return const SizedBox.shrink(); + } + + final points = trail.latLngPoints; + + return PolylineLayer( + polylines: [ + Polyline( + points: points, + strokeWidth: 4.0, + color: Colors.blue.withValues(alpha: 0.7), + borderStrokeWidth: 2.0, + borderColor: Colors.white.withValues(alpha: 0.5), + ), + ], + ); + }, + ); + } +} + +/// Widget that shows trail statistics overlay +class TrailStatsOverlay extends StatelessWidget { + const TrailStatsOverlay({super.key}); + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.toStringAsFixed(0)} m'; + } else { + return '${(meters / 1000).toStringAsFixed(2)} km'; + } + } + + String _formatDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } else if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } else { + return '${seconds}s'; + } + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, mapProvider, child) { + final trail = mapProvider.currentTrail; + final isVisible = mapProvider.isTrailVisible; + + // Don't show if trail is hidden or doesn't exist + if (!isVisible || trail == null || trail.points.isEmpty) { + return const SizedBox.shrink(); + } + + final distance = mapProvider.totalTrailDistance; + final duration = mapProvider.trailDuration; + final pointCount = trail.points.length; + + return Positioned( + top: 16, + left: 16, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.timeline, + color: Colors.blue, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Location Trail', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + _buildStatRow(Icons.straighten, _formatDistance(distance)), + const SizedBox(height: 4), + _buildStatRow(Icons.access_time, _formatDuration(duration)), + const SizedBox(height: 4), + _buildStatRow(Icons.place, '$pointCount points'), + ], + ), + ), + ); + }, + ); + } + + Widget _buildStatRow(IconData icon, String text) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: Colors.white70, + size: 16, + ), + const SizedBox(width: 6), + Text( + text, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/map/map_legend.dart b/lib/widgets/map/map_legend.dart new file mode 100644 index 0000000..7da7406 --- /dev/null +++ b/lib/widgets/map/map_legend.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import '../../l10n/app_localizations.dart'; + +class MapLegend extends StatelessWidget { + final int teamMemberCount; + final int foundPersonCount; + final int fireCount; + final int stagingAreaCount; + final int objectCount; + + const MapLegend({ + super.key, + required this.teamMemberCount, + required this.foundPersonCount, + required this.fireCount, + required this.stagingAreaCount, + required this.objectCount, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Legend', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + _LegendItem( + icon: Icons.person, + color: Theme.of(context).colorScheme.primary, + label: AppLocalizations.of(context)!.team, + count: teamMemberCount, + ), + _LegendItem( + icon: Icons.person_pin, + color: Colors.green, + label: AppLocalizations.of(context)!.found, + count: foundPersonCount, + ), + _LegendItem( + icon: Icons.local_fire_department, + color: Colors.red, + label: AppLocalizations.of(context)!.fire, + count: fireCount, + ), + _LegendItem( + icon: Icons.home_work, + color: Colors.orange, + label: AppLocalizations.of(context)!.staging, + count: stagingAreaCount, + ), + _LegendItem( + icon: Icons.inventory_2, + color: Colors.purple, + label: AppLocalizations.of(context)!.object, + count: objectCount, + ), + ], + ), + ), + ); + } +} + +class _LegendItem extends StatelessWidget { + final IconData icon; + final Color color; + final String label; + final int count; + + const _LegendItem({ + required this.icon, + required this.color, + required this.label, + required this.count, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + count.toString(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/map/map_message_overlay.dart b/lib/widgets/map/map_message_overlay.dart new file mode 100644 index 0000000..07ad24a --- /dev/null +++ b/lib/widgets/map/map_message_overlay.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import '../../models/message.dart'; +import '../../l10n/app_localizations.dart'; +import '../messages/message_bubble.dart'; + +/// Message overlay widget for displaying recent messages on the map +/// Only shown in fullscreen mode on large screens (>= 800px width) +class MapMessageOverlay extends StatefulWidget { + final List messages; + final VoidCallback? onNavigateToMessages; + final Function(String messageId)? onMessageTap; + + const MapMessageOverlay({ + super.key, + required this.messages, + this.onNavigateToMessages, + this.onMessageTap, + }); + + @override + State createState() => _MapMessageOverlayState(); +} + +class _MapMessageOverlayState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + // Scroll to bottom on initial build + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(animate: false); + }); + } + + @override + void didUpdateWidget(MapMessageOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + // Auto-scroll to bottom when new messages arrive + if (widget.messages.length > oldWidget.messages.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(animate: true); + }); + } + } + + void _scrollToBottom({bool animate = true}) { + if (_scrollController.hasClients) { + if (animate) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } else { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.messages.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Row( + children: [ + const Icon( + Icons.message, + color: Colors.white, + size: 20, + ), + SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of(context)!.recentMessages, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + '${widget.messages.length}', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + ), + ), + ], + ), + ), + // Message list + Expanded( + child: ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.all(8), + itemCount: widget.messages.length, + separatorBuilder: (context, index) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final message = widget.messages[index]; + + return MessageBubble( + message: message, + isCompact: true, + onTap: () { + widget.onMessageTap?.call(message.id); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/map/polygon_draw_handler.dart b/lib/widgets/map/polygon_draw_handler.dart new file mode 100644 index 0000000..73ecd6a --- /dev/null +++ b/lib/widgets/map/polygon_draw_handler.dart @@ -0,0 +1,304 @@ +import 'dart:math' show log, ln2; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../providers/offline_tiles_provider.dart'; + +/// Renders polygon/rectangle drawing interaction on the map. +/// +/// Shows completed polygons, in-progress vertices, and handles tap events. +class PolygonDrawLayer extends StatelessWidget { + const PolygonDrawLayer({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + final polygons = []; + final markers = []; + + // Completed polygons + for (int i = 0; i < provider.polygons.length; i++) { + final poly = provider.polygons[i]; + polygons.add(Polygon( + points: poly, + color: Colors.blue.withValues(alpha: 0.2), + borderColor: Colors.blue, + borderStrokeWidth: 2, + )); + } + + // In-progress polygon vertices + if (provider.drawingMode == DrawingMode.polygon && + provider.currentVertices.isNotEmpty) { + final verts = provider.currentVertices; + + // Draw lines between vertices + if (verts.length >= 2) { + polygons.add(Polygon( + points: verts, + color: Colors.orange.withValues(alpha: 0.1), + borderColor: Colors.orange, + borderStrokeWidth: 2, + )); + } + + // Draw vertex markers + for (final v in verts) { + markers.add(Marker( + point: v, + width: 12, + height: 12, + child: Container( + decoration: BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + )); + } + } + + // Rectangle first corner marker + if (provider.drawingMode == DrawingMode.rectangle && + provider.rectangleFirstCorner != null) { + markers.add(Marker( + point: provider.rectangleFirstCorner!, + width: 14, + height: 14, + child: Container( + decoration: BoxDecoration( + color: Colors.orange, + shape: BoxShape.rectangle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + )); + } + + return Stack( + children: [ + if (polygons.isNotEmpty) PolygonLayer(polygons: polygons), + if (markers.isNotEmpty) MarkerLayer(markers: markers), + ], + ); + }, + ); + } +} + +/// Renders the download progress overlay tiles. +class DownloadProgressLayer extends StatelessWidget { + const DownloadProgressLayer({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + if (provider.tileOverlays.isEmpty) return const SizedBox.shrink(); + + final polygons = provider.tileOverlays.map((overlay) { + return Polygon( + points: [ + LatLng(overlay.north, overlay.west), + LatLng(overlay.north, overlay.east), + LatLng(overlay.south, overlay.east), + LatLng(overlay.south, overlay.west), + ], + color: overlay.isSkipped + ? Colors.green.withValues(alpha: 0.15) + : Colors.orange.withValues(alpha: 0.15), + borderColor: + overlay.isSkipped ? Colors.green : Colors.orange, + borderStrokeWidth: 1, + ); + }).toList(); + + return PolygonLayer(polygons: polygons); + }, + ); + } +} + +/// Renders the coverage overlay for a cached style. +/// +/// Only renders tiles at the zoom level closest to the current map zoom +/// to avoid drawing thousands of rectangles at once. +class CoverageLayer extends StatelessWidget { + final double currentZoom; + + const CoverageLayer({super.key, required this.currentZoom}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + if (provider.coverageOverlays.isEmpty) { + return const SizedBox.shrink(); + } + + // Filter to tiles near the current zoom level to keep rendering fast. + // Show the zoom level that's <= current map zoom (best visual match). + final targetZoom = currentZoom.floor(); + + // Group overlays by approximate zoom level based on tile size. + // A tile at zoom z covers roughly (360/2^z) degrees of longitude. + // We filter by checking if the tile width matches the target zoom. + final filtered = []; + for (final overlay in provider.coverageOverlays) { + // Estimate the zoom level from the tile's longitude span + final lonSpan = (overlay.east - overlay.west).abs(); + if (lonSpan <= 0) continue; + final estimatedZoom = (log(360.0 / lonSpan) / ln2).round(); + + if (estimatedZoom == targetZoom || + estimatedZoom == targetZoom - 1 || + estimatedZoom == targetZoom + 1) { + filtered.add(Polygon( + points: [ + LatLng(overlay.north, overlay.west), + LatLng(overlay.north, overlay.east), + LatLng(overlay.south, overlay.east), + LatLng(overlay.south, overlay.west), + ], + color: Colors.blue.withValues(alpha: 0.1), + borderColor: Colors.blue.withValues(alpha: 0.4), + borderStrokeWidth: 1, + )); + } + + // Cap at 1000 visible tiles to avoid jank + if (filtered.length >= 1000) break; + } + + if (filtered.isEmpty) return const SizedBox.shrink(); + return PolygonLayer(polygons: filtered); + }, + ); + } +} + +/// Toolbar for drawing controls. +class DrawingToolbar extends StatelessWidget { + const DrawingToolbar({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + if (provider.isDownloading) return const SizedBox.shrink(); + + return Positioned( + right: 16, + top: 100, + child: Column( + children: [ + _ToolButton( + icon: Icons.crop_square, + label: AppLocalizations.of(context)!.rectangle, + isActive: provider.drawingMode == DrawingMode.rectangle, + onTap: () => provider.setDrawingMode( + provider.drawingMode == DrawingMode.rectangle + ? DrawingMode.none + : DrawingMode.rectangle, + ), + ), + const SizedBox(height: 8), + _ToolButton( + icon: Icons.pentagon_outlined, + label: AppLocalizations.of(context)!.polygon, + isActive: provider.drawingMode == DrawingMode.polygon, + onTap: () => provider.setDrawingMode( + provider.drawingMode == DrawingMode.polygon + ? DrawingMode.none + : DrawingMode.polygon, + ), + ), + if (provider.drawingMode == DrawingMode.polygon && + provider.currentVertices.length >= 3) ...[ + const SizedBox(height: 8), + _ToolButton( + icon: Icons.check, + label: AppLocalizations.of(context)!.finish, + isActive: false, + color: Colors.green, + onTap: () => provider.finishPolygon(), + ), + ], + if (provider.drawingMode == DrawingMode.polygon && + provider.currentVertices.isNotEmpty) ...[ + const SizedBox(height: 8), + _ToolButton( + icon: Icons.undo, + label: AppLocalizations.of(context)!.undo, + isActive: false, + onTap: () => provider.undoLastVertex(), + ), + ], + if (provider.hasPolygons) ...[ + const SizedBox(height: 8), + _ToolButton( + icon: Icons.delete_outline, + label: AppLocalizations.of(context)!.clear, + isActive: false, + color: Colors.red, + onTap: () => provider.clearPolygons(), + ), + ], + ], + ), + ); + }, + ); + } +} + +class _ToolButton extends StatelessWidget { + final IconData icon; + final String label; + final bool isActive; + final Color? color; + final VoidCallback onTap; + + const _ToolButton({ + required this.icon, + required this.label, + required this.isActive, + this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final activeColor = color ?? theme.colorScheme.primary; + + return Tooltip( + message: label, + child: Material( + elevation: 2, + shape: const CircleBorder(), + color: isActive ? activeColor : theme.colorScheme.surface, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: SizedBox( + width: 48, + height: 48, + child: Icon( + icon, + color: isActive + ? theme.colorScheme.onPrimary + : (color ?? theme.colorScheme.onSurface), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/map/trail_controls.dart b/lib/widgets/map/trail_controls.dart new file mode 100644 index 0000000..6c39c5f --- /dev/null +++ b/lib/widgets/map/trail_controls.dart @@ -0,0 +1,349 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/location_trail.dart'; +import '../../providers/map_provider.dart'; +import '../../services/gpx_service.dart'; +import '../../l10n/app_localizations.dart'; + +/// Trail management controls widget +class TrailControls extends StatelessWidget { + const TrailControls({super.key}); + + void _showTrailMenu(BuildContext context) { + final mapProvider = Provider.of(context, listen: false); + final l10n = AppLocalizations.of(context)!; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => StatefulBuilder( + builder: (context, setModalState) => SingleChildScrollView( + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(Icons.timeline, size: 24), + SizedBox(width: 12), + Text( + l10n.locationTrail, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 20), + + // Trail recording toggle + SwitchListTile( + secondary: Icon(Icons.fiber_manual_record), + title: Text('Record location trail'), + subtitle: Text( + mapProvider.isTrailRecordingEnabled + ? 'Trail recording is on' + : 'Trail recording is stopped', + ), + value: mapProvider.isTrailRecordingEnabled, + onChanged: (value) { + mapProvider.setTrailRecordingEnabled(value); + setModalState(() {}); + }, + ), + const Divider(), + + // Trail visibility toggle + SwitchListTile( + secondary: Icon(Icons.visibility), + title: Text(l10n.showTrailOnMap), + subtitle: Text( + !mapProvider.isTrailRecordingEnabled + ? 'Trail recording is stopped' + : (mapProvider.isTrailVisible + ? l10n.trailVisible + : l10n.trailHiddenRecording), + ), + value: mapProvider.isTrailVisible, + onChanged: (value) { + mapProvider.toggleTrailVisibility(); + setModalState(() {}); // Update modal UI + }, + ), + const Divider(), + const SizedBox(height: 8), + + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton.icon( + onPressed: () => _importTrail(context, mapProvider), + icon: Icon(Icons.file_upload_outlined), + label: Text(l10n.importFromClipboard), + ), + if (mapProvider.currentTrail != null && + mapProvider.currentTrail!.points.length >= 2) + OutlinedButton.icon( + onPressed: () => _exportTrail( + context, + mapProvider.currentTrail!, + customName: 'My Trail', + ), + icon: Icon(Icons.file_download_outlined), + label: Text(l10n.exportToClipboard), + ), + ], + ), + + const SizedBox(height: 16), + + // Trail stats + if (mapProvider.currentTrail != null && + mapProvider.currentTrail!.points.isNotEmpty) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.blue.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStatRow( + icon: Icons.straighten, + label: l10n.distance, + value: _formatDistance( + mapProvider.totalTrailDistance, + ), + ), + SizedBox(height: 8), + _buildStatRow( + icon: Icons.access_time, + label: l10n.duration, + value: _formatDuration(mapProvider.trailDuration), + ), + SizedBox(height: 8), + _buildStatRow( + icon: Icons.place, + label: l10n.points, + value: '${mapProvider.currentTrail!.points.length}', + ), + ], + ), + ), + + // Clear trail button + if (mapProvider.currentTrail != null && + mapProvider.currentTrail!.points.isNotEmpty) + ElevatedButton.icon( + onPressed: () { + _showClearConfirmation(context, mapProvider, l10n); + }, + icon: Icon(Icons.delete_outline), + label: Text(l10n.clearTrail), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.all(16), + ), + ), + + // No trail message + if (mapProvider.currentTrail == null || + mapProvider.currentTrail!.points.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: Column( + children: [ + const Icon( + Icons.timeline, + size: 48, + color: Colors.grey, + ), + SizedBox(height: 8), + Text( + l10n.noTrailRecorded, + style: const TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), + SizedBox(height: 8), + Text( + l10n.startTrackingToRecord, + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + + const SizedBox(height: 8), + + // Close button + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ), + ), + ), + ), + ); + } + + Future _importTrail( + BuildContext context, + MapProvider mapProvider, + ) async { + final l10n = AppLocalizations.of(context)!; + + try { + final importedTrail = await GpxService.importTrailFromFile(); + if (importedTrail == null || !context.mounted) { + return; + } + + mapProvider.replaceCurrentTrailWithImport(importedTrail); + mapProvider.clearImportedTrail(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${l10n.locationTrail}: ${importedTrail.points.length} ${l10n.points.toLowerCase()}', + ), + ), + ); + } catch (error) { + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.importFailed(error.toString()))), + ); + } + } + + Future _exportTrail( + BuildContext context, + LocationTrail trail, { + String? customName, + }) async { + final l10n = AppLocalizations.of(context)!; + + try { + final success = await GpxService.exportTrailToFile( + trail, + customName: customName, + ); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.exportFailed('unable to share GPX'))), + ); + } + } catch (error) { + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.exportFailed(error.toString()))), + ); + } + } + + void _showClearConfirmation( + BuildContext context, + MapProvider mapProvider, + AppLocalizations l10n, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.clearTrailQuestion), + content: Text(l10n.clearTrailConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () { + mapProvider.clearCurrentTrail(); + Navigator.pop(context); // Close dialog + Navigator.pop(context); // Close bottom sheet + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(l10n.clearTrail), + ), + ], + ), + ); + } + + Widget _buildStatRow({ + required IconData icon, + required String label, + required String value, + }) { + return Row( + children: [ + Icon(icon, size: 18, color: Colors.blue), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14), + ), + const Spacer(), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ], + ); + } + + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.toStringAsFixed(0)} m'; + } else { + return '${(meters / 1000).toStringAsFixed(2)} km'; + } + } + + String _formatDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m ${seconds}s'; + } else if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } else { + return '${seconds}s'; + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return FloatingActionButton.small( + heroTag: 'trail_controls', + tooltip: l10n.trailControls, + onPressed: () => _showTrailMenu(context), + child: const Icon(Icons.timeline), + ); + } +} diff --git a/lib/widgets/map_debug_info.dart b/lib/widgets/map_debug_info.dart new file mode 100644 index 0000000..35f8374 --- /dev/null +++ b/lib/widgets/map_debug_info.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; + +/// Map Debug Info Widget +/// Displays current zoom level and visible bounds in bottom-left corner +class MapDebugInfo extends StatefulWidget { + final MapController mapController; + + const MapDebugInfo({super.key, required this.mapController}); + + @override + State createState() => _MapDebugInfoState(); +} + +class _MapDebugInfoState extends State { + StreamSubscription? _mapEventSubscription; + + @override + void initState() { + super.initState(); + // Listen to map events and trigger rebuild + _mapEventSubscription = widget.mapController.mapEventStream.listen((event) { + if (mounted) { + setState(() { + // Rebuild when map moves, zooms, or rotates + }); + } + }); + } + + @override + void dispose() { + _mapEventSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + try { + final camera = widget.mapController.camera; + final bounds = camera.visibleBounds; + + return Card( + color: Colors.black.withValues(alpha: 0.7), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Z: ${camera.zoom.toStringAsFixed(2)}', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 2), + Text( + 'N: ${bounds.north.toStringAsFixed(5)}', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontFamily: 'monospace', + ), + ), + Text( + 'S: ${bounds.south.toStringAsFixed(5)}', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontFamily: 'monospace', + ), + ), + Text( + 'E: ${bounds.east.toStringAsFixed(5)}', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontFamily: 'monospace', + ), + ), + Text( + 'W: ${bounds.west.toStringAsFixed(5)}', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ); + } catch (e) { + // Map not ready yet + return const SizedBox.shrink(); + } + } +} diff --git a/lib/widgets/map_markers.dart b/lib/widgets/map_markers.dart new file mode 100644 index 0000000..519b088 --- /dev/null +++ b/lib/widgets/map_markers.dart @@ -0,0 +1,438 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import '../models/contact.dart'; +import '../models/sar_marker.dart'; +import '../models/sar_template.dart'; +import '../l10n/app_localizations.dart'; +import '../utils/location_formats.dart'; + +class MapMarkers { + static List createTeamMemberMarkers( + List contacts, + BuildContext context, { + Function(Contact)? onContactTap, + double mapRotation = 0, + }) { + return contacts + .map((contact) { + final location = contact.displayLocation; + if (location == null) return null; + + return Marker( + point: location, + width: 80, + height: 100, + rotate: false, // Don't rotate the entire marker with map + child: Transform.rotate( + angle: -mapRotation * 3.14159265359 / 180, + child: GestureDetector( + onTap: () { + if (onContactTap != null) { + onContactTap(contact); + } else { + _showContactInfo(context, contact); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Location update time indicator + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: _getLocationAgeColor(contact), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + contact.timeSinceLocationUpdate, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + // Marker icon or emoji + Container( + decoration: BoxDecoration( + color: _getContactTypeColor(contact, context), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: contact.roleEmoji != null + ? Text( + contact.roleEmoji!, + style: const TextStyle(fontSize: 18), + ) + : Icon( + _getContactTypeIcon(contact), + color: Colors.white, + size: 18, + ), + ), + const SizedBox(height: 2), + // Name label (without emoji) + Container( + constraints: const BoxConstraints(maxWidth: 80), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + contact.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ); + }) + .whereType() + .toList(); + } + + static List createSarMarkers( + List sarMarkers, + BuildContext context, { + Function(SarMarker)? onSarMarkerTap, + double mapRotation = 0, + }) { + return sarMarkers.map((marker) { + return Marker( + point: marker.location, + width: 90, + height: 100, + rotate: false, // Don't rotate the entire marker with map + child: Transform.rotate( + angle: -mapRotation * 3.14159265359 / 180, + child: GestureDetector( + onTap: () { + if (onSarMarkerTap != null) { + onSarMarkerTap(marker); + } else { + _showSarMarkerInfo(context, marker); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Time ago label + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: _getSarMarkerColor(marker), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + marker.timeAgo, + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + // Marker emoji/icon + Container( + decoration: BoxDecoration( + color: _getSarMarkerColor(marker), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: Text( + marker.emoji, // Use custom emoji if available + style: const TextStyle(fontSize: 18), + ), + ), + const SizedBox(height: 2), + // Type label + Container( + constraints: const BoxConstraints(maxWidth: 90), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Builder( + builder: (context) { + // Debug: Print what we're actually displaying + debugPrint('🗺️ [MapMarker] Displaying SAR marker:'); + debugPrint(' marker.notes: "${marker.notes}"'); + debugPrint(' marker.type: ${marker.type}'); + debugPrint( + ' marker.type.displayName: ${marker.type.displayName}', + ); + debugPrint( + ' marker.displayName: ${marker.displayName}', + ); + + return Text( + marker.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ); + }, + ), + ), + ], + ), + ), + ), + ); + }).toList(); + } + + static void _showContactInfo(BuildContext context, Contact contact) { + // Import provider to get all contacts and SAR markers for detailed view + // This will be handled by importing the screen's detailed compass dialog + // Since we can't directly access _DetailedCompassDialog from here, + // we'll pass a callback to the screen + // For now, show the simple dialog as a fallback + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + if (contact.roleEmoji != null) + Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24)) + else + Icon(Icons.person, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Expanded(child: Text(contact.displayName)), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (contact.displayLocation != null) ...[ + _InfoRow( + 'Location', + formatCoordinates( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + CoordinateDisplayFormat.utm, + ), + ), + ], + if (contact.telemetry?.batteryMilliVolts != null) + _InfoRow( + 'Voltage', + '${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V' + '${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}', + ) + else if (contact.displayBattery != null) + _InfoRow('Battery', '${contact.displayBattery!.round()}%'), + if (contact.telemetry?.temperature != null) + _InfoRow( + 'Temperature', + '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C', + ), + if (contact.telemetry?.humidity != null) + _InfoRow( + 'Humidity', + '${contact.telemetry!.humidity!.toStringAsFixed(1)}%', + ), + if (contact.telemetry?.pressure != null) + _InfoRow( + 'Pressure', + '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa', + ), + _InfoRow('Last Seen', contact.timeSinceLastSeen), + _InfoRow('Public Key', contact.publicKeyShort), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.close), + ), + ], + ), + ); + } + + static void _showSarMarkerInfo(BuildContext context, SarMarker marker) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Text( + marker.emoji, + style: const TextStyle(fontSize: 24), + ), // Use custom emoji if available + const SizedBox(width: 8), + Expanded(child: Text(marker.displayName)), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoRow( + 'Location', + formatCoordinates( + marker.location.latitude, + marker.location.longitude, + CoordinateDisplayFormat.utm, + ), + ), + _InfoRow('Reported', marker.timeAgo), + if (marker.senderName != null) + _InfoRow('Reporter', marker.senderName!), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.close), + ), + ], + ), + ); + } + + static Color _getLocationAgeColor(Contact contact) { + final updateTime = contact.locationUpdateTime; + if (updateTime == null) return Colors.grey; + + final diff = DateTime.now().difference(updateTime); + if (diff.inMinutes < 5) return Colors.green; // Very recent + if (diff.inMinutes < 30) return Colors.lightBlue; // Recent + if (diff.inHours < 2) return Colors.orange; // Getting old + return Colors.red; // Stale + } + + static Color _getSarMarkerColor(SarMarker marker) { + // If marker has a color index, use it (new format) + if (marker.colorIndex != null && + marker.colorIndex! >= 0 && + marker.colorIndex! < 8) { + final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!); + final hexCode = colorHex.replaceAll('#', ''); + return Color(int.parse('FF$hexCode', radix: 16)); + } + + // Otherwise fall back to type-based colors (old format or backward compatibility) + switch (marker.type) { + case SarMarkerType.foundPerson: + return Colors.green; + case SarMarkerType.fire: + return Colors.red; + case SarMarkerType.stagingArea: + return Colors.orange; + case SarMarkerType.object: + return Colors.purple; + case SarMarkerType.unknown: + return Colors.grey; + } + } + + static Color _getContactTypeColor(Contact contact, BuildContext context) { + switch (contact.type) { + case ContactType.chat: + return Theme.of(context).colorScheme.primary; // Blue for team members + case ContactType.repeater: + return Colors.deepPurple; // Purple for repeaters + case ContactType.room: + return Colors.teal; // Teal for rooms + case ContactType.sensor: + return Colors.green; // Green for sensors + case ContactType.channel: + return Colors.orange; // Orange for channels + case ContactType.none: + return Colors.grey; + } + } + + static IconData _getContactTypeIcon(Contact contact) { + switch (contact.type) { + case ContactType.chat: + return Icons.person; // Person for team members + case ContactType.repeater: + return Icons.router; // Router icon for repeaters + case ContactType.room: + return Icons.forum; // Forum/chat icon for rooms + case ContactType.sensor: + return Icons.sensors; // Sensors icon for sensor nodes + case ContactType.channel: + return Icons.public; // Public icon for channels + case ContactType.none: + return Icons.help_outline; + } + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + + const _InfoRow(this.label, this.value); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 90, + child: Text( + '$label:', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + Expanded(child: Text(value)), + ], + ), + ); + } +} diff --git a/lib/widgets/messages/custom_map_sar_update_sheet.dart b/lib/widgets/messages/custom_map_sar_update_sheet.dart new file mode 100644 index 0000000..1213a61 --- /dev/null +++ b/lib/widgets/messages/custom_map_sar_update_sheet.dart @@ -0,0 +1,472 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; +import '../../models/sar_template.dart'; +import '../../providers/contacts_provider.dart'; +import '../../services/sar_template_service.dart'; +import 'sar_update_sheet.dart' show TemplateChip; + +class CustomMapSarUpdateSheet extends StatefulWidget { + final String mapName; + final String mapId; + final String pointLabel; + final Future Function( + String emoji, + String name, + Uint8List? roomPublicKey, + bool sendToChannel, + bool sendToAllContacts, + int colorIndex, + ) + onSend; + + const CustomMapSarUpdateSheet({ + super.key, + required this.mapName, + required this.mapId, + required this.pointLabel, + required this.onSend, + }); + + @override + State createState() => + _CustomMapSarUpdateSheetState(); +} + +class _CustomMapSarUpdateSheetState extends State { + final SarTemplateService _templateService = SarTemplateService(); + final TextEditingController _notesController = TextEditingController(); + + List _templates = []; + SarTemplate? _selectedTemplate; + Contact? _selectedContact; + bool _sendToAllContacts = false; + + @override + void initState() { + super.initState(); + _initializeTemplates(); + _setDefaultDestination(); + } + + Future _initializeTemplates() async { + if (!_templateService.isInitialized) { + await _templateService.initialize(); + } + if (!mounted) return; + setState(() { + _templates = _templateService.templates; + if (_templates.isNotEmpty) { + _selectedTemplate = _templates.first; + } + }); + } + + void _setDefaultDestination() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final contactsProvider = context.read(); + final roomsAndChannels = contactsProvider.roomsAndChannels; + final teamContacts = contactsProvider.chatContacts; + + if (teamContacts.length > 1) { + setState(() { + _sendToAllContacts = true; + _selectedContact = null; + }); + return; + } + + if (teamContacts.length == 1) { + setState(() { + _sendToAllContacts = false; + _selectedContact = teamContacts.first; + }); + return; + } + + if (roomsAndChannels.any((c) => c.isRoom)) { + setState(() { + _sendToAllContacts = false; + _selectedContact = roomsAndChannels.firstWhere((c) => c.isRoom); + }); + return; + } + + if (roomsAndChannels.isNotEmpty) { + setState(() { + _sendToAllContacts = false; + _selectedContact = roomsAndChannels.first; + }); + } + }); + } + + @override + void dispose() { + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + final bottomSafeArea = MediaQuery.of(context).padding.bottom; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return AnimatedPadding( + padding: EdgeInsets.only(bottom: keyboardHeight), + duration: const Duration(milliseconds: 100), + child: Container( + height: MediaQuery.of(context).size.height * 0.88, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + children: [ + Text( + AppLocalizations.of(context)!.sendSarMarker, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + AppLocalizations.of(context)!.customCaveMapPoint, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(width: 48), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 80 + bottomSafeArea, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.markerType, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ..._templates.map((template) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TemplateChip( + template: template, + isSelected: _selectedTemplate?.id == template.id, + onTap: () => + setState(() => _selectedTemplate = template), + ), + ); + }), + SizedBox(height: 24), + Text( + AppLocalizations.of(context)!.sendTo, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Consumer( + builder: (context, contactsProvider, child) { + final teamContacts = contactsProvider.chatContacts; + final roomsAndChannels = + contactsProvider.roomsAndChannels; + final destinations = [ + ...teamContacts, + ...roomsAndChannels.where((c) => c.isRoom), + ...roomsAndChannels.where((c) => c.isChannel), + ]; + + if (destinations.isEmpty && teamContacts.isEmpty) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + ), + ), + child: Text( + AppLocalizations.of( + context, + )!.noDestinationsAvailableLabel, + ), + ); + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _sendToAllContacts + ? 'all_contacts' + : _selectedContact?.publicKeyHex, + hint: Text( + AppLocalizations.of(context)!.selectDestination, + ), + dropdownColor: + colorScheme.surfaceContainerHighest, + isExpanded: true, + items: [ + if (teamContacts.isNotEmpty) + DropdownMenuItem( + value: 'all_contacts', + child: Row( + children: [ + const Icon(Icons.group, size: 18), + const SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of( + context, + )!.allTeamContacts, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ...destinations.map((contact) { + final icon = contact.isChat + ? Icons.person + : contact.isRoom + ? Icons.storage + : Icons.public; + return DropdownMenuItem( + value: contact.publicKeyHex, + child: Row( + children: [ + Icon(icon, size: 18), + const SizedBox(width: 12), + Expanded( + child: Text( + contact.getLocalizedDisplayName( + context, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }), + ], + onChanged: (value) { + setState(() { + if (value == 'all_contacts') { + _sendToAllContacts = true; + _selectedContact = null; + } else { + _sendToAllContacts = false; + _selectedContact = destinations.firstWhere( + (c) => c.publicKeyHex == value, + ); + } + }); + }, + ), + ), + ); + }, + ), + const SizedBox(height: 24), + Text( + AppLocalizations.of(context)!.mapPoint, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.map_outlined, + size: 20, + color: Colors.blue, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.mapName, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + widget.pointLabel, + style: TextStyle( + color: colorScheme.onSurface, + fontFamily: 'monospace', + fontSize: 13, + ), + ), + const SizedBox(height: 4), + Text( + 'Map ID: ${widget.mapId}', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + 'Additional notes (optional)', + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + decoration: InputDecoration( + hintText: AppLocalizations.of( + context, + )!.addAdditionalDetails, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.all(16), + ), + ), + ], + ), + ), + ), + Container( + padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomSafeArea), + decoration: BoxDecoration( + color: colorScheme.surface, + border: Border( + top: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.2), + ), + ), + ), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () async { + if (_selectedTemplate == null || + (!_sendToAllContacts && _selectedContact == null)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.selectMarkerTypeAndDestination, + ), + ), + ); + return; + } + + await widget.onSend( + _selectedTemplate!.emoji, + _notesController.text.trim().isEmpty + ? _selectedTemplate!.name + : _notesController.text.trim(), + _selectedContact?.isChannel == true + ? null + : _selectedContact?.publicKey, + _selectedContact?.isChannel == true, + _sendToAllContacts, + _templates.indexOf(_selectedTemplate!), + ); + + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: Icon(Icons.send), + label: Text(AppLocalizations.of(context)!.send), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart new file mode 100644 index 0000000..75c6595 --- /dev/null +++ b/lib/widgets/messages/image_message_bubble.dart @@ -0,0 +1,759 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_avif/flutter_avif.dart'; +import 'package:provider/provider.dart'; +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../../providers/app_provider.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; +import '../../providers/image_provider.dart' as ip; +import '../../providers/messages_provider.dart'; +import '../../utils/image_message_parser.dart'; +import '../../l10n/app_localizations.dart'; +import '../../utils/transmission_target_resolver.dart'; +import 'transfer_timeout.dart'; + +/// A message bubble that shows a received or sent image. +/// +/// On first render the image is not yet fetched (only the IE2 envelope is +/// known). The user taps the thumbnail placeholder → IR2 fetch request is +/// sent → binary fragments stream in → bubble rebuilds with the full image. +class ImageMessageBubble extends StatefulWidget { + final Message message; + final bool isSentByMe; + + const ImageMessageBubble({ + super.key, + required this.message, + required this.isSentByMe, + }); + + @override + State createState() => _ImageMessageBubbleState(); +} + +class _ImageMessageBubbleState extends State { + static const int _maxFetchHops = 3; + static const Duration _recentInboundActivityWindow = Duration(seconds: 3); + bool _isRequesting = false; + bool _isPartialRequest = false; + String? _errorText; + Timer? _requestTimeoutTimer; + + @override + void dispose() { + _requestTimeoutTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final radioBw = context.select( + (p) => p.deviceInfo.radioBw, + ); + final radioSf = context.select( + (p) => p.deviceInfo.radioSf, + ); + final radioCr = context.select( + (p) => p.deviceInfo.radioCr, + ); + final envelope = ImageEnvelope.tryParse(widget.message.text); + if (envelope == null) return const SizedBox.shrink(); + + return Consumer( + builder: (context, imageProvider, _) { + final transferCount = context.select( + (provider) => provider.transferCountForSession( + imageSessionId: envelope.sessionId, + ), + ); + final contactsProvider = context.read(); + final session = imageProvider.session(envelope.sessionId); + final sender = TransmissionTargetResolver.resolveLocalTarget( + contactsProvider: contactsProvider, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderName: widget.message.senderName, + ); + final effectivePathLen = sender != null && sender.routeHasPath + ? sender.routeHopCount + : widget.message.pathLen; + final isComplete = imageProvider.isComplete(envelope.sessionId); + final eta = imageProvider.estimateRemainingTransferTime( + envelope.sessionId, + ); + + if (_isRequesting && isComplete) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isRequesting = false; + _isPartialRequest = false; + _errorText = null; + }); + } + }); + } + + final received = session?.receivedCount ?? 0; + final total = session?.total ?? envelope.total; + final imageBytes = isComplete ? session?.imageBytes : null; + final fragmentPresence = + session?.fragments.map((fragment) => fragment != null).toList() ?? + List.filled(total, false); + final isReceivingData = + !_isRequesting && + !isComplete && + _hasRecentInboundActivity( + lastReceivedAt: session?.lastFragmentAt, + received: received, + total: total, + ); + + return GestureDetector( + onTap: isComplete + ? () => _showFullScreen(context, imageBytes!) + : null, + child: Container( + constraints: const BoxConstraints(maxWidth: 256), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Image area: 256×256 placeholder or actual image + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildImageArea( + context, + imageBytes: imageBytes, + isComplete: isComplete, + isRequesting: _isRequesting, + isReceivingData: isReceivingData, + received: received, + total: total, + fragmentPresence: fragmentPresence, + envelope: envelope, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + pathLen: effectivePathLen, + ), + ), + const SizedBox(height: 4), + // Status line + Text( + _statusText( + isComplete: isComplete, + isRequesting: _isRequesting, + isReceivingData: isReceivingData, + isPartialRequest: _isPartialRequest, + received: received, + total: total, + envelope: envelope, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + error: _errorText, + isSentByMe: widget.isSentByMe, + eta: eta, + pathLen: effectivePathLen, + transferCount: transferCount, + fetchingMissingLabel: AppLocalizations.of( + context, + )!.fetchingMissingImageFragments, + loadingLabel: AppLocalizations.of(context)!.loadingImage, + receivingLabel: AppLocalizations.of( + context, + )!.receivingImage, + tapToLoadLabel: AppLocalizations.of(context)!.tapToLoad, + ), + style: TextStyle( + fontSize: 11, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildImageArea( + BuildContext context, { + required Uint8List? imageBytes, + required bool isComplete, + required bool isRequesting, + required bool isReceivingData, + required int received, + required int total, + required List fragmentPresence, + required ImageEnvelope envelope, + required int? radioBw, + required int? radioSf, + required int? radioCr, + required int pathLen, + }) { + if (isComplete && imageBytes != null) { + return AspectRatio( + aspectRatio: 1.0, + child: AvifImage.memory(imageBytes, fit: BoxFit.cover), + ); + } + + // Placeholder with fetch/progress UI. + return AspectRatio( + aspectRatio: 1.0, + child: Container( + color: Colors.grey.shade800, + child: Stack( + alignment: Alignment.center, + children: [ + if (isRequesting) ...[ + Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _PacketBlockProgress( + presence: fragmentPresence, + activeColor: Theme.of(context).colorScheme.primary, + highlightMissing: _isPartialRequest, + ), + const SizedBox(height: 8), + Text( + '$received/$total', + style: const TextStyle(color: Colors.white, fontSize: 11), + ), + ], + ), + ), + Positioned( + top: 8, + right: 8, + child: IconButton( + onPressed: () => _cancelReceive(envelope.sessionId), + icon: const Icon(Icons.close, size: 20), + color: Colors.white70, + tooltip: AppLocalizations.of(context)!.cancelImageReceive, + ), + ), + ] else if (_errorText != null) ...[ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.broken_image, color: Colors.red, size: 36), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => _requestAndFetch( + envelope, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + pathLen: pathLen, + ), + icon: const Icon(Icons.refresh, size: 18), + label: Text(AppLocalizations.of(context)!.retry), + style: TextButton.styleFrom( + foregroundColor: Colors.white70, + ), + ), + ], + ), + ] else ...[ + // Tap-to-load icon. + IconButton( + onPressed: isReceivingData + ? null + : () => _requestAndFetch( + envelope, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + pathLen: pathLen, + ), + icon: Icon( + isReceivingData + ? Icons.downloading_rounded + : Icons.download_rounded, + size: 40, + ), + color: Colors.white70, + tooltip: isReceivingData + ? AppLocalizations.of(context)!.imageAlreadyBeingReceived + : AppLocalizations.of(context)!.loadImage, + ), + ], + ], + ), + ), + ); + } + + Future _requestAndFetch( + ImageEnvelope envelope, { + int? radioBw, + int? radioSf, + int? radioCr, + int pathLen = 0, + }) async { + if (_isRequesting) return; + + final conn = context.read(); + final imageProvider = context.read(); + imageProvider.resumeIncomingSession(envelope.sessionId); + final contactsProvider = context.read(); + final appProvider = context.read(); + var resolution = await TransmissionTargetResolver.resolveFetchTarget( + contactsProvider: contactsProvider, + refreshContacts: conn.getContacts, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderName: widget.message.senderName, + maxFetchHops: _maxFetchHops, + ); + if (!mounted) return; + + if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchImage, + AppLocalizations.of(context)!.senderContactUnknown, + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchImage, + AppLocalizations.of(context)!.senderRouteUnknown, + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchImage, + AppLocalizations.of( + context, + )!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'), + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.unreachable) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchImage, + AppLocalizations.of(context)!.senderRouteNoPathResponse, + ); + return; + } + + var sender = resolution.target!; + var routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + await conn.getContacts(); + if (!mounted) return; + resolution = await TransmissionTargetResolver.resolveFetchTarget( + contactsProvider: contactsProvider, + refreshContacts: conn.getContacts, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderName: widget.message.senderName, + maxFetchHops: _maxFetchHops, + ); + if (!mounted) return; + if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchImage, + AppLocalizations.of(context)!.senderContactUnknown, + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchImage, + AppLocalizations.of(context)!.senderRouteUnknown, + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchImage, + AppLocalizations.of( + context, + )!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'), + ); + return; + } + sender = resolution.target!; + routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchImage, + AppLocalizations.of(context)!.senderRouteNoRawResponse, + ); + return; + } + } + + if (sender.routeHopCount >= 2) { + _showToast( + AppLocalizations.of( + context, + )!.imageFetchOverHops('${sender.routeHopCount}'), + ); + } + + setState(() => _errorText = null); + final deviceKey = conn.deviceInfo.publicKey; + if (deviceKey == null || deviceKey.length < 6) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchImage, + AppLocalizations.of(context)!.deviceKeyUnavailable, + ); + return; + } + + final requesterKey6 = deviceKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + // If we already have some fragments, request only what's missing. + final missing = imageProvider.missingFragmentIndices(envelope.sessionId); + final isPartialResume = + missing.isNotEmpty && missing.length < envelope.total; + setState(() { + _isRequesting = true; + _isPartialRequest = isPartialResume; + _errorText = null; + }); + final request = isPartialResume + ? ImageFetchRequest( + sessionId: envelope.sessionId, + want: 'missing', + missingIndices: missing, + requesterKey6: requesterKey6, + ) + : ImageFetchRequest( + sessionId: envelope.sessionId, + requesterKey6: requesterKey6, + ); + + final payload = request.encodeBinary(); + try { + debugPrint( + '📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}', + ); + await conn.sendRawVoicePacket( + contactPath: sender.outPath, + contactPathLen: sender.routeEncodedPathLen, + payload: payload, + ); + } catch (_) { + if (mounted) { + _showToast(AppLocalizations.of(context)!.imageFetchFailedToSendRequest); + setState(() { + _isRequesting = false; + _isPartialRequest = false; + _errorText = AppLocalizations.of(context)!.imageUnavailable; + }); + } + return; + } + if (!mounted) return; + + // Timeout = 2× estimated LoRa airtime (min 30s). + final effectivePathLen = sender.routeHasPath + ? sender.routeHopCount + : pathLen; + final txEstimate = estimateImageTransmitDuration( + fragmentCount: missing.isEmpty ? envelope.total : missing.length, + sizeBytes: missing.isEmpty + ? envelope.sizeBytes + : (envelope.sizeBytes * missing.length / envelope.total).round(), + pathLen: effectivePathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + _requestTimeoutTimer?.cancel(); + _requestTimeoutTimer = TransferTimeout.start( + txEstimate: txEstimate, + onTimeout: () { + if (mounted && + _isRequesting && + !imageProvider.isComplete(envelope.sessionId)) { + _showToast(AppLocalizations.of(context)!.imageFetchTimedOut); + setState(() { + _isRequesting = false; + _isPartialRequest = false; + _errorText = AppLocalizations.of(context)!.imageFetchTimedOut; + }); + } + }, + ); + } + + void _showToast(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 3)), + ); + } + + void _cancelReceive(String sessionId) { + if (!mounted) return; + _requestTimeoutTimer?.cancel(); + context.read().cancelIncomingSession(sessionId); + _showToast(AppLocalizations.of(context)!.imageReceiveCanceled); + setState(() { + _isRequesting = false; + _isPartialRequest = false; + _errorText = AppLocalizations.of(context)!.imageReceiveCanceled; + }); + } + + void _clearRequestState() { + if (!mounted) return; + setState(() { + _isRequesting = false; + _isPartialRequest = false; + }); + } + + Future _showBlockingAlert(String title, String message) async { + if (!mounted) return; + _showToast('$title: $message'); + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + + static String _statusText({ + required bool isComplete, + required bool isRequesting, + required bool isReceivingData, + required bool isPartialRequest, + required int received, + required int total, + required ImageEnvelope envelope, + required int pathLen, + required int? radioBw, + required int? radioSf, + required int? radioCr, + required String? error, + required bool isSentByMe, + required Duration? eta, + required int transferCount, + required String fetchingMissingLabel, + required String loadingLabel, + required String receivingLabel, + required String tapToLoadLabel, + }) { + final txEstimate = estimateImageTransmitDuration( + fragmentCount: envelope.total, + sizeBytes: envelope.sizeBytes, + pathLen: pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + final txEstimateLabel = _formatTransmitEstimate(txEstimate); + + if (error != null) return error; + if (isRequesting) { + final etaLabel = _formatEta(eta); + final actionLabel = isPartialRequest + ? '📥 $fetchingMissingLabel' + : '📥 $loadingLabel'; + return '$actionLabel $received/$total · $etaLabel · $txEstimateLabel'; + } + if (isReceivingData) { + final etaLabel = _formatEta(eta); + return '📥 $receivingLabel $received/$total · $etaLabel · $txEstimateLabel'; + } + if (isComplete) { + final base = + '🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}'; + return isSentByMe + ? '$base · ${envelope.total} seg · ${_formatTransferCount(transferCount)} · $txEstimateLabel' + : '$base · $txEstimateLabel'; + } + return isSentByMe + ? '🖼️ ${envelope.width}×${envelope.height} · ${_formatTransferCount(transferCount)} · $txEstimateLabel' + : '🖼️ $tapToLoadLabel · ${envelope.width}×${envelope.height} · $txEstimateLabel'; + } + + static String _formatTransmitEstimate(Duration value) { + if (value.inSeconds < 60) return '~${value.inSeconds}s tx'; + final minutes = value.inMinutes; + final seconds = value.inSeconds % 60; + return '~${minutes}m ${seconds}s tx'; + } + + static String _formatEta(Duration? eta) { + if (eta == null || eta <= Duration.zero) return 'ETA --'; + if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s'; + final minutes = eta.inMinutes; + final seconds = eta.inSeconds % 60; + return 'ETA ~${minutes}m ${seconds}s'; + } + + static String _formatTransferCount(int transferCount) { + return '$transferCount transfer${transferCount == 1 ? '' : 's'}'; + } + + bool _hasRecentInboundActivity({ + required DateTime? lastReceivedAt, + required int received, + required int total, + }) { + if (lastReceivedAt == null || received <= 0 || received >= total) { + return false; + } + return DateTime.now().difference(lastReceivedAt) <= + _recentInboundActivityWindow; + } + + void _showFullScreen(BuildContext context, Uint8List imageBytes) { + showGeneralDialog( + context: context, + barrierColor: Colors.black, + barrierDismissible: true, + barrierLabel: AppLocalizations.of(context)!.closeImagePreview, + pageBuilder: (dialogContext, animation, secondaryAnimation) => Material( + color: Colors.black, + child: Stack( + children: [ + Positioned.fill( + child: InteractiveViewer( + minScale: 1.0, + maxScale: 1000.0, + clipBehavior: Clip.none, + boundaryMargin: const EdgeInsets.all(100000), + child: SizedBox.expand( + child: AvifImage.memory(imageBytes, fit: BoxFit.cover), + ), + ), + ), + Positioned( + top: 16, + right: 16, + child: SafeArea( + child: IconButton( + onPressed: () => Navigator.of(dialogContext).pop(), + icon: const Icon(Icons.close), + color: Colors.white, + tooltip: AppLocalizations.of(context)!.close, + ), + ), + ), + ], + ), + ), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ); + }, + transitionDuration: const Duration(milliseconds: 150), + ); + } +} + +class _PacketBlockProgress extends StatelessWidget { + final List presence; + final Color activeColor; + final bool highlightMissing; + + const _PacketBlockProgress({ + required this.presence, + required this.activeColor, + this.highlightMissing = false, + }); + + @override + Widget build(BuildContext context) { + if (presence.isEmpty) { + return const SizedBox(width: 96, height: 12); + } + + final bucketCount = presence.length <= 24 ? presence.length : 24; + final bucketFill = List.generate(bucketCount, (bucketIndex) { + final start = (bucketIndex * presence.length) ~/ bucketCount; + final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount; + final safeEnd = end <= start ? start + 1 : end; + final slice = presence.sublist(start, safeEnd); + final received = slice.where((value) => value).length; + return slice.isEmpty ? 0.0 : received / slice.length; + }); + final missingColor = highlightMissing + ? Colors.amberAccent + : Colors.white.withValues(alpha: 0.14); + + return SizedBox( + width: 120, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (final fill in bucketFill) + Expanded( + child: Container( + height: 12, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: fill > 0 + ? activeColor.withValues(alpha: 0.18 + (0.72 * fill)) + : missingColor.withValues( + alpha: highlightMissing ? 0.45 : 0.14, + ), + borderRadius: BorderRadius.circular(2), + border: Border.all( + color: fill > 0 + ? Colors.white.withValues(alpha: 0.18) + : missingColor.withValues( + alpha: highlightMissing ? 0.7 : 0.18, + ), + width: 0.5, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart new file mode 100644 index 0000000..7007af6 --- /dev/null +++ b/lib/widgets/messages/message_bubble.dart @@ -0,0 +1,3096 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:latlong2/latlong.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../../models/sar_marker.dart'; +import '../../models/sar_template.dart'; +import '../../models/map_drawing.dart'; +import '../../models/map_coordinate_space.dart'; +import '../../providers/messages_provider.dart'; +import '../../providers/channels_provider.dart'; +import '../../providers/contacts_provider.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/drawing_provider.dart'; +import '../../providers/voice_provider.dart'; +import '../../providers/image_provider.dart' as ip; +import '../../providers/app_provider.dart'; +import '../drawing_minimap_preview.dart'; +import '../../models/ble_packet_log.dart'; +import '../../models/message_contact_location.dart'; +import '../../services/sar_template_service.dart'; +import '../../services/location_tracking_service.dart'; +import '../../utils/toast_logger.dart'; +import '../../utils/sar_message_parser.dart'; +import '../../utils/key_comparison.dart'; +import '../../utils/voice_message_parser.dart'; +import '../../utils/image_message_parser.dart'; +import '../../utils/tictactoe_message_parser.dart'; +import '../../utils/location_formats.dart'; +import '../../l10n/app_localizations.dart'; +import '../../utils/message_extensions.dart'; +import '../../utils/log_rx_route_decoder.dart'; +import '../../models/message_transfer_details.dart'; +import '../../screens/add_contact_screen.dart'; +import 'voice_message_bubble.dart'; +import 'image_message_bubble.dart'; +import 'tictactoe_message_bubble.dart'; +import 'message_bubble_header.dart'; +import 'message_bubble_signal.dart'; +import 'system_message_bubble.dart'; + +/// Reusable message bubble widget that displays messages with various types: +/// - Regular text messages (channel or direct) +/// - SAR markers (styled with SAR-specific colors and badges) +/// - Drawing messages (with minimap preview) +/// - System messages (compact log-style display) +/// - Grouped messages (expandable recipient list) +class MessageBubble extends StatefulWidget { + final Message message; + final int receivedCopies; + final VoidCallback? onTap; + final VoidCallback? onReply; + final bool isHighlighted; + final VoidCallback? onNavigateToMap; + + /// Compact mode for fullscreen map overlay (simplified styling) + final bool isCompact; + + const MessageBubble({ + super.key, + required this.message, + this.receivedCopies = 1, + this.onTap, + this.onReply, + this.isHighlighted = false, + this.onNavigateToMap, + this.isCompact = false, + }); + + @override + State createState() => _MessageBubbleState(); +} + +class _MessageBubbleState extends State { + static const int _meshcoreAdvertMinBytes = 98; + static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]'); + static final RegExp _linkPattern = RegExp( + r'(?:(?:https?:\/\/)|(?:www\.)|(?:meshcore:\/\/))[^\s<]+', + caseSensitive: false, + ); + static final RegExp _rawMeshcoreAdvertPattern = RegExp( + r'(? _linkRecognizers = []; + + @override + void dispose() { + _disposeLinkRecognizers(); + super.dispose(); + } + + @override + void didUpdateWidget(MessageBubble oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.id != widget.message.id) { + _isExpanded = false; + return; + } + + // Force rebuild when message properties change (especially recipient statuses) + if (oldWidget.message.id == widget.message.id) { + // Same message, but properties might have changed + setState(() { + // Trigger rebuild to show updated delivery counts + }); + } + } + + void _toggleExpanded() { + setState(() { + _isExpanded = !_isExpanded; + }); + } + + void _disposeLinkRecognizers() { + for (final recognizer in _linkRecognizers) { + recognizer.dispose(); + } + _linkRecognizers.clear(); + } + + String _trimTrailingUrlPunctuation(String value) { + var trimmed = value; + while (trimmed.isNotEmpty) { + final lastChar = trimmed[trimmed.length - 1]; + final shouldTrim = switch (lastChar) { + '.' || ',' || '!' || '?' || ':' || ';' => true, + ')' => '('.allMatches(trimmed).length < ')'.allMatches(trimmed).length, + ']' => '['.allMatches(trimmed).length < ']'.allMatches(trimmed).length, + '}' => '{'.allMatches(trimmed).length < '}'.allMatches(trimmed).length, + _ => false, + }; + if (!shouldTrim) { + break; + } + trimmed = trimmed.substring(0, trimmed.length - 1); + } + return trimmed; + } + + Uri? _parseMessageLink(String value) { + final trimmed = _trimTrailingUrlPunctuation(value); + if (trimmed.isEmpty) { + return null; + } + + if (_isRawMeshcoreAdvert(trimmed)) { + return Uri.parse('meshcore://$trimmed'); + } + + final normalized = trimmed.startsWith('www.') + ? 'https://$trimmed' + : trimmed; + final uri = Uri.tryParse(normalized); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + return null; + } + + return uri; + } + + bool _isRawMeshcoreAdvert(String value) { + if (!_rawMeshcoreAdvertPattern.hasMatch(value)) { + return false; + } + return value.length.isEven && value.length >= _meshcoreAdvertMinBytes * 2; + } + + Future _openMessageLink(String rawUrl) async { + final uri = _parseMessageLink(rawUrl); + if (uri == null) { + ToastLogger.error(context, AppLocalizations.of(context)!.invalidLink); + return; + } + + if (uri.scheme.toLowerCase() == 'meshcore') { + if (!mounted) { + return; + } + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AddContactScreen(initialAdvert: rawUrl), + ), + ); + return; + } + + try { + if (!await canLaunchUrl(uri)) { + if (!mounted) { + return; + } + ToastLogger.error( + context, + AppLocalizations.of(context)!.cannotOpenLink, + ); + return; + } + + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (error) { + if (!mounted) { + return; + } + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToOpenLink, + ); + } + } + + Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) { + _disposeLinkRecognizers(); + + final matches = _messageTokenPattern.allMatches(text).toList(); + if (matches.isEmpty) { + return Text(text, style: baseBodyStyle); + } + + final textColor = + baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface; + final linkColor = Theme.of(context).colorScheme.primary; + final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1; + final backgroundColor = Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12); + final borderColor = Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.25); + + final spans = []; + var cursor = 0; + + for (final match in matches) { + if (match.start > cursor) { + spans.add( + TextSpan( + text: text.substring(cursor, match.start), + style: baseBodyStyle, + ), + ); + } + + final rawMatch = match.group(0) ?? ''; + final mentionName = match.group(1)?.trim() ?? ''; + if (_mentionPattern.hasMatch(rawMatch) && mentionName.isNotEmpty) { + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 1), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Text( + '@$mentionName', + style: baseBodyStyle?.copyWith( + color: textColor, + fontSize: mentionFontSize, + fontWeight: FontWeight.w600, + height: 1.0, + ), + ), + ), + ), + ); + } else if (_linkPattern.hasMatch(rawMatch) || + _isRawMeshcoreAdvert(rawMatch)) { + final trimmedUrl = _trimTrailingUrlPunctuation(rawMatch); + final trailingText = rawMatch.substring(trimmedUrl.length); + final uri = _parseMessageLink(rawMatch); + if (uri != null) { + final recognizer = TapGestureRecognizer() + ..onTap = () => _openMessageLink(rawMatch); + _linkRecognizers.add(recognizer); + spans.add( + TextSpan( + text: trimmedUrl, + style: baseBodyStyle?.copyWith( + color: linkColor, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ), + recognizer: recognizer, + ), + ); + if (trailingText.isNotEmpty) { + spans.add(TextSpan(text: trailingText, style: baseBodyStyle)); + } + } else { + spans.add(TextSpan(text: rawMatch, style: baseBodyStyle)); + } + } else { + spans.add(TextSpan(text: rawMatch, style: baseBodyStyle)); + } + + cursor = match.end; + } + + if (cursor < text.length) { + spans.add(TextSpan(text: text.substring(cursor), style: baseBodyStyle)); + } + + return Text.rich(TextSpan(children: spans, style: baseBodyStyle)); + } + + void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) { + if (!widget.message.isRead && + !widget.message.isSentMessage && + !widget.message.isSystemMessage) { + context.read().markAsRead(widget.message.id); + } + widget.onTap?.call(); + } + + Future _retryFailedMessage( + BuildContext context, + Message failedMessage, + ) async { + final connectionProvider = context.read(); + final messagesProvider = context.read(); + + if (!connectionProvider.deviceInfo.isConnected) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.notConnectedToDevice, + ); + return; + } + + try { + Contact? roomContact; + if (failedMessage.messageType == MessageType.contact) { + if (failedMessage.recipientPublicKey == null) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.cannotRetryMissingRecipient, + ); + return; + } + + final contactsProvider = context.read(); + roomContact = contactsProvider.contacts.where((c) { + return c.publicKey.length >= + failedMessage.recipientPublicKey!.length && + c.publicKey.matches(failedMessage.recipientPublicKey!); + }).firstOrNull; + } + + // Resend the message + if (failedMessage.messageType == MessageType.contact) { + // Direct message retry (for SAR markers sent to rooms) + if (failedMessage.recipientPublicKey == null) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.cannotRetryMissingRecipient, + ); + return; + } + + final prepared = messagesProvider.prepareMessageForRetry( + failedMessage.id, + ); + if (!prepared) { + return; + } + + final sentSuccessfully = await connectionProvider.sendTextMessage( + contactPublicKey: failedMessage.recipientPublicKey!, + text: failedMessage.text, + messageId: failedMessage.id, + contact: roomContact, + ); + + if (!context.mounted) return; + + if (!sentSuccessfully) { + messagesProvider.markMessageFailed(failedMessage.id); + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToResendMessage, + ); + } + } else if (failedMessage.messageType == MessageType.channel) { + final prepared = messagesProvider.prepareMessageForRetry( + failedMessage.id, + ); + if (!prepared) { + return; + } + + // Channel message retry + await connectionProvider.sendChannelMessage( + channelIdx: failedMessage.channelIdx ?? 0, + text: failedMessage.text, + messageId: failedMessage.id, + ); + + if (!context.mounted) return; + } + } catch (e) { + if (!context.mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.retryFailed('$e'), + ); + } + } + + void _showMessageOptions(BuildContext context) { + final parentContext = context; + final l10n = AppLocalizations.of(context)!; + final connectionProvider = context.read(); + final selfPublicKey = connectionProvider.deviceInfo.publicKey; + final isOwnMessage = + widget.message.isSentMessage || + widget.message.isFromSelf(selfPublicKey); + + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) => Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isOwnMessage && widget.onReply != null) + ListTile( + leading: Icon(Icons.reply), + title: Text(l10n.reply), + onTap: () { + Navigator.pop(sheetContext); + widget.onReply?.call(); + }, + ), + // Copy text option + ListTile( + leading: Icon(Icons.copy), + title: Text(l10n.copyText), + onTap: () { + Clipboard.setData(ClipboardData(text: widget.message.text)); + Navigator.pop(sheetContext); + ToastLogger.success(parentContext, l10n.textCopiedToClipboard); + }, + ), + // Save as Template option (only for SAR markers without existing template) + if (widget.message.isSarMarker) + Builder( + builder: (_) { + // Extract emoji from SAR message + final sarInfo = SarMessageParser.parse(widget.message.text); + if (sarInfo == null || sarInfo.emoji.isEmpty) { + return const SizedBox.shrink(); + } + + // Check if template with this emoji already exists + final sarTemplateService = SarTemplateService(); + final templateExists = sarTemplateService.templates.any( + (t) => t.emoji == sarInfo.emoji, + ); + + if (templateExists) { + return const SizedBox.shrink(); + } + + return ListTile( + leading: Icon(Icons.bookmark_add), + title: Text(l10n.saveAsTemplate), + onTap: () { + Navigator.pop(sheetContext); + _saveAsTemplate(parentContext); + }, + ); + }, + ), + // Share location option (only for SAR markers with GPS coordinates) + if (widget.message.isSarMarker && + widget.message.sarGpsCoordinates != null) + ListTile( + leading: Icon(Icons.share_location), + title: Text(l10n.shareLocation), + onTap: () { + Navigator.pop(sheetContext); + _shareLocation(parentContext); + }, + ), + // Navigate to drawing option (only for drawing messages) + if (widget.message.isDrawing && widget.message.drawingId != null) + ListTile( + leading: Icon(Icons.map), + title: Text(l10n.navigateToDrawing), + onTap: () { + Navigator.pop(sheetContext); + _navigateToDrawing(parentContext); + }, + ), + // Copy coordinates option (only for drawing messages) + if (widget.message.isDrawing && widget.message.drawingId != null) + ListTile( + leading: Icon(Icons.copy), + title: Text(l10n.copyCoordinates), + onTap: () { + Navigator.pop(sheetContext); + _copyDrawingCoordinates(parentContext); + }, + ), + // Delete drawing & message option (only for drawing messages) + if (widget.message.isDrawing && widget.message.drawingId != null) + ListTile( + leading: Icon(Icons.delete_forever, color: Colors.red), + title: Text( + l10n.deleteDrawingAndMessage, + style: const TextStyle(color: Colors.red), + ), + onTap: () { + Navigator.pop(sheetContext); + _showDeleteDrawingConfirmation(parentContext); + }, + ), + // Details option + ListTile( + leading: Icon(Icons.data_object), + title: Text(l10n.technicalDetails), + onTap: () { + Navigator.pop(sheetContext); + _showTechnicalDetails(parentContext); + }, + ), + // Delete message option + ListTile( + leading: Icon(Icons.delete, color: Colors.red), + title: Text( + l10n.delete, + style: const TextStyle(color: Colors.red), + ), + onTap: () { + Navigator.pop(sheetContext); + _showDeleteConfirmation(parentContext); + }, + ), + ], + ), + ), + ); + } + + void _showTechnicalDetails(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final connectionProvider = context.read(); + final radioBw = connectionProvider.deviceInfo.radioBw; + final radioSf = connectionProvider.deviceInfo.radioSf; + final radioCr = connectionProvider.deviceInfo.radioCr; + final contactsProvider = context.read(); + final messagesProvider = context.read(); + final voiceProvider = context.read(); + final imageProvider = context.read(); + final selfPublicKey = connectionProvider.deviceInfo.publicKey; + final isOwnMessage = + widget.message.isSentMessage || + widget.message.isFromSelf(selfPublicKey); + + String? senderName; + Contact? senderContact; + if (widget.message.senderPublicKeyPrefix != null) { + final senderKeyHex = widget.message.senderPublicKeyPrefix! + .sublist( + 0, + widget.message.senderPublicKeyPrefix!.length < 6 + ? widget.message.senderPublicKeyPrefix!.length + : 6, + ) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + senderContact = contactsProvider.contacts + .where((c) => c.publicKeyHex.startsWith(senderKeyHex)) + .firstOrNull; + senderName = senderContact?.advName; + } + + String? recipientName; + if (widget.message.recipientPublicKey != null) { + final recipientKeyHex = widget.message.recipientPublicKey! + .sublist( + 0, + widget.message.recipientPublicKey!.length < 6 + ? widget.message.recipientPublicKey!.length + : 6, + ) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final recipientContact = contactsProvider.contacts + .where((c) => c.publicKeyHex.startsWith(recipientKeyHex)) + .firstOrNull; + recipientName = recipientContact?.advName; + } + + final storedLocationSnapshot = messagesProvider.getMessageContactLocation( + widget.message.id, + ); + final receptionDetails = messagesProvider.getMessageReceptionDetails( + widget.message.id, + ); + final transferDetails = messagesProvider.getMessageTransferDetails( + widget.message.id, + ); + + final envelope = VoiceEnvelope.tryParseText(widget.message.text); + final voiceSession = widget.message.voiceId != null + ? voiceProvider.session(widget.message.voiceId!) + : null; + + final imageEnvelope = ImageEnvelope.tryParse(widget.message.text); + final imageSession = imageEnvelope != null + ? imageProvider.session(imageEnvelope.sessionId) + : null; + final imageTxEstimate = imageEnvelope != null + ? estimateImageTransmitDuration( + fragmentCount: imageEnvelope.total, + sizeBytes: imageEnvelope.sizeBytes, + pathLen: widget.message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ) + : Duration.zero; + final voiceTxEstimate = voiceSession != null + ? estimateVoiceTransmitDurationFromPackets( + packets: voiceSession.packets, + pathLen: widget.message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ) + : envelope != null + ? estimateVoiceTransmitDuration( + mode: envelope.mode, + packetCount: envelope.total, + durationMs: envelope.durationMs, + pathLen: widget.message.pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ) + : Duration.zero; + + final senderPrefixHex = widget.message.senderPublicKeyPrefix + ?.map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final recipientKey = widget.message.recipientPublicKey; + final recipientPrefixHex = recipientKey + ?.sublist(0, recipientKey.length < 6 ? recipientKey.length : 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final matchedRxLog = _findBestMatchingRxLog( + connectionProvider.bleService.packetLogs, + widget.message, + ); + final packetPathBytes = _extractPathBytesFromLog(matchedRxLog); + final packetPathHex = (receptionDetails?.pathBytes ?? packetPathBytes) + ?.map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); + final lastEchoRelayHash = receptionDetails?.pathBytes?.isNotEmpty == true + ? receptionDetails!.pathBytes!.last + .toRadixString(16) + .padLeft(2, '0') + .toUpperCase() + : null; + final lastEchoBytesReport = _formatPathBytesReport( + receptionDetails?.pathBytes, + ); + final snrDb = + receptionDetails?.snrDb ?? + matchedRxLog?.logRxDataInfo?.snrDb ?? + (widget.message.lastEchoSnrRaw != null + ? (widget.message.lastEchoSnrRaw!.toSigned(8) / 4.0) + : null); + final rssiDbm = + receptionDetails?.rssiDbm ?? + matchedRxLog?.logRxDataInfo?.rssiDbm ?? + widget.message.lastEchoRssiDbm; + final retryCause = _retryCauseLabel(widget.message); + final retryResult = _retryResultLabel(widget.message); + final retryMode = _retryModeLabel(widget.message); + final routeMetadata = context + .read() + .getMessageRouteMetadata(widget.message.id); + final messageLocationSnapshot = _resolveDetailsLocationSnapshot( + storedSnapshot: storedLocationSnapshot, + senderContact: senderContact, + connectionProvider: connectionProvider, + isOwnMessage: isOwnMessage, + ); + + final rawLines = [ + 'Message ID: ${widget.message.id}', + 'Type: ${widget.message.messageType.name}', + 'Text type: ${widget.message.textType.name}', + 'Own message: $isOwnMessage', + 'Sent message: ${widget.message.isSentMessage}', + 'Read: ${widget.message.isRead}', + 'Status: ${widget.message.deliveryStatus.name}', + 'Path length (nodes/hops): ${_hopDebugLabel(widget.message)}', + 'Sender timestamp: ${widget.message.senderTimestamp} (${widget.message.sentAt.toIso8601String()})', + 'Received at (RFC3339): ${_formatRfc3339(widget.message.receivedAt)}', + 'Channel index: ${widget.message.channelIdx ?? '-'}', + 'Echo count: ${widget.message.echoCount}', + 'Last echo relay hash: ${lastEchoRelayHash ?? '-'}', + 'Last echo path bytes: ${receptionDetails?.pathBytesHex ?? '-'}', + 'Last echo bytes report: ${lastEchoBytesReport ?? '-'}', + 'Last echo RSSI: ${widget.message.lastEchoRssiDbm ?? '-'}', + 'Last echo SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}', + 'Matched RX RSSI: ${rssiDbm ?? '-'}', + 'Matched RX SNR: ${snrDb?.toStringAsFixed(2) ?? '-'}', + 'Matched path bytes: ${packetPathHex ?? '-'}', + 'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}', + 'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}', + 'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}', + 'Received copies: ${widget.receivedCopies}', + 'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}', + 'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}', + 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', + 'Retry attempt: ${widget.message.retryAttempt}', + 'Used flood fallback: ${widget.message.usedFloodFallback}', + 'Retry cause: ${retryCause ?? '-'}', + 'Retry mode: ${retryMode ?? '-'}', + 'Retry result: ${retryResult ?? '-'}', + 'Sender key prefix: ${senderPrefixHex ?? '-'}', + 'Sender name: ${senderName ?? widget.message.senderName ?? '-'}', + '${widget.message.isSentMessage ? "Sent from location" : "Sender location at receipt"}: ${messageLocationSnapshot?.formattedCoordinates ?? '-'}', + '${widget.message.isSentMessage ? "Sent from source" : "Sender location source"}: ${messageLocationSnapshot?.technicalSourceLabel ?? '-'}', + '${widget.message.isSentMessage ? "Sent from timestamp" : "Sender location timestamp"}: ${messageLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}', + 'Recipient key prefix: ${recipientPrefixHex ?? '-'}', + 'Recipient name: ${recipientName ?? '-'}', + 'Drawing flag: ${widget.message.isDrawing}', + 'Drawing ID: ${widget.message.drawingId ?? '-'}', + 'SAR flag: ${widget.message.isSarMarker}', + 'Voice flag: ${widget.message.isVoice}', + 'Voice ID: ${widget.message.voiceId ?? '-'}', + 'Text length: ${widget.message.text.length}', + ]; + + if (transferDetails != null) { + rawLines.add('Transfers served: ${transferDetails.totalTransfers}'); + rawLines.add( + 'Downloaded by: ${_formatDownloaderSummary(transferDetails)}', + ); + } + + if (widget.message.isVoice) { + rawLines.add('--- Voice Technical ---'); + if (envelope != null) { + rawLines.add('Envelope format: VE3 compact'); + rawLines.add( + 'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})', + ); + rawLines.add('Segments total (envelope): ${envelope.total}'); + rawLines.add( + 'Estimated duration ms (envelope): ${envelope.durationMs}', + ); + rawLines.add('Envelope ver: ${envelope.version}'); + } else { + rawLines.add('Envelope format: unknown'); + } + + if (voiceSession != null) { + rawLines.add('Session present locally: yes'); + rawLines.add('Session mode: ${voiceSession.mode.label}'); + rawLines.add( + 'Session segments received/total: ${voiceSession.receivedCount}/${voiceSession.total}', + ); + rawLines.add('Session complete: ${voiceSession.isComplete}'); + rawLines.add( + 'Session estimated duration s: ${voiceSession.estimatedDurationSeconds.toStringAsFixed(2)}', + ); + rawLines.add( + 'Estimated voice tx: ~${voiceTxEstimate.inSeconds}s (current radio)', + ); + } else { + rawLines.add('Session present locally: no'); + if (voiceTxEstimate > Duration.zero) { + rawLines.add( + 'Estimated voice tx: ~${voiceTxEstimate.inSeconds}s (current radio)', + ); + } + } + } + + if (imageEnvelope != null) { + rawLines.add('--- Image Technical ---'); + rawLines.add('Envelope format: IE1'); + rawLines.add('Session ID: ${imageEnvelope.sessionId}'); + rawLines.add( + 'Image format: ${imageEnvelope.format.label} (id=${imageEnvelope.format.id})', + ); + rawLines.add( + 'Dimensions: ${imageEnvelope.width}×${imageEnvelope.height}', + ); + rawLines.add('Fragments total (envelope): ${imageEnvelope.total}'); + rawLines.add('Compressed size (envelope): ${imageEnvelope.sizeBytes} B'); + rawLines.add( + 'Estimated image tx: ~${imageTxEstimate.inSeconds}s (current radio)', + ); + rawLines.add('Envelope ver: ${imageEnvelope.version}'); + + if (imageSession != null) { + rawLines.add('Session present locally: yes'); + rawLines.add( + 'Fragments received/total: ${imageSession.receivedCount}/${imageSession.total}', + ); + rawLines.add('Session complete: ${imageSession.isComplete}'); + final kb = (imageSession.imageBytes?.length ?? 0) / 1024.0; + rawLines.add( + 'Reassembled size: ${imageSession.imageBytes != null ? '${kb.toStringAsFixed(1)} kB' : '-'}', + ); + } else { + rawLines.add('Session present locally: no'); + } + } + + void copyField(BuildContext feedbackContext, String value) { + Clipboard.setData(ClipboardData(text: value)); + ToastLogger.success(feedbackContext, l10n.textCopiedToClipboard); + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) => SafeArea( + child: SizedBox( + height: MediaQuery.of(sheetContext).size.height * 0.85, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + l10n.messageTechnicalDetails, + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + ), + IconButton( + onPressed: () => Navigator.pop(sheetContext), + icon: Icon(Icons.close), + tooltip: l10n.close, + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (messageLocationSnapshot != null) ...[ + _techSection( + sheetContext, + icon: Icons.location_on, + title: l10n.location, + child: Column( + children: [ + _buildLocationPreviewMap( + sheetContext, + messageLocationSnapshot.location, + ), + const SizedBox(height: 12), + _detailRow( + sheetContext, + label: l10n.coordinates, + value: messageLocationSnapshot + .formattedCoordinates, + onCopy: () => copyField( + sheetContext, + messageLocationSnapshot.formattedCoordinates, + ), + ), + _detailRow( + sheetContext, + label: l10n.source, + value: messageLocationSnapshot + .technicalSourceLabel, + ), + _detailRow( + sheetContext, + label: l10n.captured, + value: _formatRfc3339( + messageLocationSnapshot.sourceTimestamp ?? + messageLocationSnapshot.capturedAt, + ), + onCopy: () => copyField( + sheetContext, + _formatRfc3339( + messageLocationSnapshot.sourceTimestamp ?? + messageLocationSnapshot.capturedAt, + ), + ), + ), + ], + ), + ), + ], + if (widget.message.lastEchoRssiDbm != null || + snrDb != null || + rssiDbm != null) ...[ + SizedBox(height: 12), + _techSection( + sheetContext, + icon: Icons.network_check, + title: l10n.linkQuality, + child: Column( + children: [ + if (rssiDbm != null) + _signalRow( + sheetContext, + label: l10n.rssi, + valueLabel: '$rssiDbm dBm', + normalized: + ((rssiDbm.toDouble() + 120.0) / 70.0) + .clamp(0.0, 1.0), + color: rssiDbm >= -80 + ? Colors.green + : rssiDbm >= -95 + ? Colors.amber + : Colors.redAccent, + ), + if (snrDb != null) ...[ + SizedBox(height: 8), + _signalRow( + sheetContext, + label: l10n.snr, + valueLabel: '${snrDb.toStringAsFixed(1)} dB', + normalized: ((snrDb + 20.0) / 40.0).clamp( + 0.0, + 1.0, + ), + color: snrDb >= 10 + ? Colors.green + : snrDb >= 0 + ? Colors.amber + : Colors.redAccent, + ), + ], + ], + ), + ), + ], + SizedBox(height: 12), + _techSection( + sheetContext, + icon: Icons.tune, + title: l10n.delivery, + child: Column( + children: [ + _detailRow( + sheetContext, + label: l10n.status, + value: widget.message.deliveryStatus.name, + ), + _detailRow( + sheetContext, + label: l10n.receivedRfc3339, + value: _formatRfc3339(widget.message.receivedAt), + onCopy: () => copyField( + sheetContext, + _formatRfc3339(widget.message.receivedAt), + ), + ), + if (widget.message.expectedAckTag != null) + _detailRow( + sheetContext, + label: l10n.expectedAckTag, + value: widget.message.expectedAckTag! + .toString(), + ), + if (receptionDetails?.senderToReceiptMs != null) + _detailRow( + sheetContext, + label: l10n.senderToReceipt, + value: _formatDurationMs( + receptionDetails!.senderToReceiptMs!, + ), + ), + if (receptionDetails?.estimatedTransmitMs != null) + _detailRow( + sheetContext, + label: l10n.estimatedTx, + value: _formatDurationMs( + receptionDetails!.estimatedTransmitMs!, + ), + ), + if (receptionDetails?.postTransmitDelayMs != null) + _detailRow( + sheetContext, + label: l10n.posttxDelay, + value: _formatDurationMs( + receptionDetails!.postTransmitDelayMs!, + ), + ), + if (widget.receivedCopies > 1) + _detailRow( + sheetContext, + label: l10n.receivedCopies, + value: '${widget.receivedCopies}', + ), + if (lastEchoRelayHash != null) + _detailRow( + sheetContext, + label: l10n.lastEchoRelay, + value: lastEchoRelayHash, + onCopy: () => + copyField(sheetContext, lastEchoRelayHash), + ), + if (receptionDetails?.pathBytesHex + case final echoPath?) + _detailRow( + sheetContext, + label: l10n.lastEchoPath, + value: echoPath, + onCopy: () => copyField(sheetContext, echoPath), + ), + if (lastEchoBytesReport != null) + _detailRow( + sheetContext, + label: l10n.lastEchoBytesReport, + value: lastEchoBytesReport, + onCopy: () => copyField( + sheetContext, + lastEchoBytesReport, + ), + ), + if (widget.message.suggestedTimeoutMs != null) + _detailRow( + sheetContext, + label: l10n.ackTimeout, + value: + '${widget.message.suggestedTimeoutMs} ms', + ), + if (retryCause != null) + _detailRow( + sheetContext, + label: l10n.retryCause, + value: retryCause, + ), + if (retryMode != null) + _detailRow( + sheetContext, + label: l10n.retryMode, + value: retryMode, + ), + if (widget.message.roundTripTimeMs != null) + _detailRow( + sheetContext, + label: l10n.roundTrip, + value: '${widget.message.roundTripTimeMs} ms', + ), + if (widget.message.retryAttempt > 0) + _detailRow( + sheetContext, + label: l10n.retryAttempt, + value: '${widget.message.retryAttempt}/4', + ), + if (widget.message.lastRetryAt != null) + _detailRow( + sheetContext, + label: l10n.lastRetry, + value: _formatRfc3339( + widget.message.lastRetryAt!, + ), + onCopy: () => copyField( + sheetContext, + _formatRfc3339(widget.message.lastRetryAt!), + ), + ), + if (widget.message.usedFloodFallback) + _detailRow( + sheetContext, + label: l10n.floodFallback, + value: l10n.yes, + ), + if (routeMetadata?.canonicalPath + case final routePath?) + _detailRow( + sheetContext, + label: l10n.selectedPath, + value: routePath, + onCopy: () => + copyField(sheetContext, routePath), + ), + if (retryResult != null) + _detailRow( + sheetContext, + label: l10n.retryResult, + value: retryResult, + ), + if (packetPathHex != null) + _detailRow( + sheetContext, + label: l10n.pathBytes, + value: packetPathHex, + onCopy: () => + copyField(sheetContext, packetPathHex), + ), + ], + ), + ), + SizedBox(height: 12), + _techSection( + sheetContext, + icon: Icons.badge, + title: l10n.identity, + child: Column( + children: [ + _detailRow( + sheetContext, + label: l10n.messageId, + value: widget.message.id, + onCopy: () => + copyField(sheetContext, widget.message.id), + ), + _detailRow( + sheetContext, + label: l10n.sender, + value: + senderName ?? + widget.message.senderName ?? + 'Unknown', + ), + if (senderPrefixHex != null) + _detailRow( + sheetContext, + label: l10n.senderKey, + value: senderPrefixHex, + onCopy: () => + copyField(sheetContext, senderPrefixHex), + ), + if (recipientName != null) + _detailRow( + sheetContext, + label: l10n.recipient, + value: recipientName, + ), + if (recipientPrefixHex != null) + _detailRow( + sheetContext, + label: l10n.recipientKey, + value: recipientPrefixHex, + onCopy: () => + copyField(sheetContext, recipientPrefixHex), + ), + ], + ), + ), + if (widget.message.isVoice) ...[ + SizedBox(height: 12), + _techSection( + sheetContext, + icon: Icons.graphic_eq, + title: l10n.voice, + child: Column( + children: [ + _detailRow( + sheetContext, + label: l10n.voiceId, + value: widget.message.voiceId ?? '-', + ), + _detailRow( + sheetContext, + label: l10n.envelope, + value: envelope != null + ? 'VE3 compact' + : l10n.unknown, + ), + if (voiceSession != null) + _detailRow( + sheetContext, + label: l10n.sessionProgress, + value: + '${voiceSession.receivedCount}/${voiceSession.total} segments', + ), + if (voiceSession != null) + _detailRow( + sheetContext, + label: l10n.complete, + value: voiceSession.isComplete + ? l10n.yes + : l10n.no, + ), + if (transferDetails != null) + _detailRow( + sheetContext, + label: l10n.transfers, + value: '${transferDetails.totalTransfers}', + ), + if (transferDetails != null && + transferDetails.downloaders.isNotEmpty) + _detailRow( + sheetContext, + label: l10n.downloadedBy, + value: _formatDownloaderSummary( + transferDetails, + ), + ), + if (voiceTxEstimate > Duration.zero) + _detailRow( + sheetContext, + label: l10n.estimatedTx, + value: voiceTxEstimate.inSeconds < 60 + ? '~${voiceTxEstimate.inSeconds}s' + : '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s', + ), + ], + ), + ), + ], + if (imageEnvelope != null) ...[ + SizedBox(height: 12), + _techSection( + sheetContext, + icon: Icons.image_outlined, + title: l10n.image, + child: Column( + children: [ + _detailRow( + sheetContext, + label: l10n.envelope, + value: 'IE1', + ), + _detailRow( + sheetContext, + label: l10n.format, + value: imageEnvelope.format.label, + ), + _detailRow( + sheetContext, + label: l10n.dimensions, + value: + '${imageEnvelope.width}×${imageEnvelope.height}', + ), + _detailRow( + sheetContext, + label: l10n.segments, + value: imageSession != null + ? '${imageSession.receivedCount}/${imageSession.total}' + : '${imageEnvelope.total}', + ), + if (imageSession != null) + _detailRow( + sheetContext, + label: l10n.complete, + value: imageSession.isComplete + ? l10n.yes + : l10n.no, + ), + if (transferDetails != null) + _detailRow( + sheetContext, + label: l10n.transfers, + value: '${transferDetails.totalTransfers}', + ), + if (transferDetails != null && + transferDetails.downloaders.isNotEmpty) + _detailRow( + sheetContext, + label: l10n.downloadedBy, + value: _formatDownloaderSummary( + transferDetails, + ), + ), + if (imageTxEstimate > Duration.zero) + _detailRow( + sheetContext, + label: l10n.estimatedTx, + value: imageTxEstimate.inSeconds < 60 + ? '~${imageTxEstimate.inSeconds}s' + : '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s', + ), + ], + ), + ), + ], + const SizedBox(height: 8), + ExpansionTile( + tilePadding: EdgeInsets.zero, + dense: true, + visualDensity: VisualDensity.compact, + title: Text( + l10n.rawDump, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(sheetContext) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + rawLines.join('\n'), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildLocationPreviewMap(BuildContext context, LatLng location) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 180, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCenter: location, + initialZoom: 15, + interactionOptions: const flutter_map.InteractionOptions( + flags: flutter_map.InteractiveFlag.none, + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.meshcore.sar', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: location, + width: 40, + height: 40, + child: Icon( + widget.message.isSentMessage + ? Icons.near_me + : Icons.location_on, + color: Theme.of(context).colorScheme.primary, + size: 32, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + MessageContactLocation? _resolveDetailsLocationSnapshot({ + required MessageContactLocation? storedSnapshot, + required Contact? senderContact, + required ConnectionProvider connectionProvider, + required bool isOwnMessage, + }) { + if (storedSnapshot != null) { + return storedSnapshot; + } + + if (isOwnMessage) { + final advLat = connectionProvider.deviceInfo.advLat; + final advLon = connectionProvider.deviceInfo.advLon; + if (advLat != null && advLon != null && (advLat != 0 || advLon != 0)) { + return MessageContactLocation( + location: LatLng(advLat / 1e6, advLon / 1e6), + source: 'shared', + capturedAt: widget.message.sentAt, + sourceTimestamp: widget.message.sentAt, + ); + } + + final currentPosition = LocationTrackingService().currentPosition; + if (currentPosition != null) { + return MessageContactLocation( + location: LatLng(currentPosition.latitude, currentPosition.longitude), + source: 'shared', + capturedAt: currentPosition.timestamp, + sourceTimestamp: currentPosition.timestamp, + ); + } + } + + final contactLocation = senderContact?.displayLocation; + if (contactLocation != null) { + final source = senderContact?.telemetry?.gpsLocation != null + ? 'telemetry' + : 'advert'; + return MessageContactLocation( + location: contactLocation, + source: source, + capturedAt: + senderContact?.locationUpdateTime ?? widget.message.receivedAt, + sourceTimestamp: senderContact?.locationUpdateTime, + ); + } + + return null; + } + + Widget _techSection( + BuildContext context, { + required IconData icon, + required String title, + required Widget child, + }) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(icon, size: 14), + const SizedBox(width: 6), + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 8), + child, + ], + ), + ); + } + + Widget _detailRow( + BuildContext context, { + required String label, + required String value, + VoidCallback? onCopy, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox( + width: 110, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.75), + ), + ), + ), + Expanded( + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12), + ), + ), + if (onCopy != null) + IconButton( + onPressed: onCopy, + icon: const Icon(Icons.copy, size: 14), + visualDensity: VisualDensity.compact, + tooltip: 'Copy $label', + ), + ], + ), + ); + } + + String _formatDownloaderSummary(MessageTransferDetails transferDetails) { + return transferDetails.downloaders.map(_formatDownloaderLabel).join(', '); + } + + String _formatDownloaderLabel(MessageTransferDownloader downloader) { + final name = downloader.requesterName?.trim(); + final base = name != null && name.isNotEmpty + ? '$name (${downloader.requesterKey6})' + : downloader.requesterKey6; + return downloader.transferCount > 1 + ? '$base ×${downloader.transferCount}' + : base; + } + + Widget _signalRow( + BuildContext context, { + required String label, + required String valueLabel, + required double normalized, + required Color color, + }) { + return Row( + children: [ + SizedBox( + width: 42, + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12), + ), + ), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: LinearProgressIndicator( + minHeight: 8, + value: normalized, + backgroundColor: color.withValues(alpha: 0.15), + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 74, + child: Text( + valueLabel, + textAlign: TextAlign.right, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ], + ); + } + + String _formatRfc3339(DateTime dateTime) { + final utc = dateTime.toUtc(); + String two(int v) => v.toString().padLeft(2, '0'); + String four(int v) => v.toString().padLeft(4, '0'); + final fraction = utc.millisecond == 0 + ? '' + : '.${utc.millisecond.toString().padLeft(3, '0')}'; + + return '${four(utc.year)}-${two(utc.month)}-${two(utc.day)}' + 'T${two(utc.hour)}:${two(utc.minute)}:${two(utc.second)}' + '${fraction}Z'; + } + + String _formatDurationMs(int durationMs) { + if (durationMs >= 60000) { + final minutes = durationMs ~/ 60000; + final seconds = (durationMs % 60000) ~/ 1000; + return '${minutes}m ${seconds}s'; + } + if (durationMs >= 1000) { + return '${(durationMs / 1000).toStringAsFixed(durationMs >= 10000 ? 0 : 1)} s'; + } + return '$durationMs ms'; + } + + String? _formatPathBytesReport(List? pathBytes) { + if (pathBytes == null || pathBytes.isEmpty) { + return null; + } + + final byteHex = pathBytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase()) + .toList(); + final indexedHops = byteHex + .asMap() + .entries + .map((entry) => '#${entry.key + 1}=${entry.value}') + .join(', '); + final byteLabel = pathBytes.length == 1 ? 'byte' : 'bytes'; + return '${pathBytes.length} $byteLabel [${byteHex.join(' ')}] • hops $indexedHops'; + } + + BlePacketLog? _findBestMatchingRxLog( + List logs, + Message message, + ) { + if (message.pathLen < 0 || message.pathLen >= 255) return null; + final expectedPayloadType = message.messageType == MessageType.channel + ? 0x05 + : 0x02; + BlePacketLog? bestLog; + var bestDeltaMs = 999999999; + + for (final log in logs) { + if (log.responseCode != 0x88) continue; // pushLogRxData + if (log.rawData.length < 6) continue; + + // Logged frame format: + // [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null) continue; + if (decoded.payloadType != expectedPayloadType) continue; + if (decoded.hopCount != message.pathLen) continue; + + final deltaMs = + (log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); + if (deltaMs < bestDeltaMs) { + bestDeltaMs = deltaMs; + bestLog = log; + } + } + + if (bestDeltaMs > 30000) return null; + return bestLog; + } + + List? _extractPathBytesFromLog(BlePacketLog? log) { + if (log == null) return null; + final decoded = LogRxRouteDecoder.decode(log.rawData); + if (decoded == null || decoded.pathBytes.isEmpty) return null; + return decoded.pathBytes; + } + + void _showDeleteConfirmation(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.deleteMessage), + content: Text( + widget.message.isDrawing && widget.message.drawingId != null + ? '${l10n.deleteMessageConfirmation} This will also remove the linked drawing from the map.' + : l10n.deleteMessageConfirmation, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () { + final messagesProvider = context.read(); + messagesProvider.deleteMessage(widget.message.id); + + // Also delete the drawing if this is a drawing message + if (widget.message.isDrawing && + widget.message.drawingId != null) { + final drawingProvider = context.read(); + drawingProvider.removeDrawing(widget.message.drawingId!); + } + + Navigator.pop(context); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(l10n.delete), + ), + ], + ), + ); + } + + void _shareLocation(BuildContext context) { + if (widget.message.sarGpsCoordinates == null) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.noGpsCoordinatesAvailable, + ); + return; + } + + final coords = widget.message.sarGpsCoordinates!; + + // Get SAR marker type emoji/name + String markerInfo = ''; + if (widget.message.sarMarkerType != null) { + markerInfo = widget.message.sarMarkerType!.emoji; + if (widget.message.sarNotes != null && + widget.message.sarNotes!.isNotEmpty) { + markerInfo += ' ${widget.message.sarNotes}'; + } + } else if (widget.message.sarCustomEmoji != null) { + markerInfo = widget.message.sarCustomEmoji!; + if (widget.message.sarNotes != null && + widget.message.sarNotes!.isNotEmpty) { + markerInfo += ' ${widget.message.sarNotes}'; + } + } + + // Format coordinates with 6 decimal places (≈0.1m precision) + final lat = coords.latitude.toStringAsFixed(6); + final lon = coords.longitude.toStringAsFixed(6); + + // Build share text + final shareText = AppLocalizations.of(context)!.shareLocationText( + markerInfo, + lat, + lon, + 'https://www.google.com/maps/search/?api=1&query=$lat,$lon', + ); + + // Share the location + SharePlus.instance.share( + ShareParams( + text: shareText, + subject: AppLocalizations.of(context)!.sarLocationShare, + ), + ); + } + + Future _saveAsTemplate(BuildContext context) async { + if (!widget.message.isSarMarker) { + ToastLogger.error(context, AppLocalizations.of(context)!.notASarMarker); + return; + } + + try { + // Parse the SAR message to create a template + final template = SarTemplate.fromSarMessage(widget.message.text); + + // Get SAR template service + final sarTemplateService = SarTemplateService(); + + // Check if template with this emoji already exists + final existingTemplates = sarTemplateService.templates + .where((t) => t.emoji == template.emoji) + .toList(); + + if (existingTemplates.isNotEmpty) { + if (!context.mounted) return; + ToastLogger.warning( + context, + AppLocalizations.of(context)!.templateAlreadyExists, + ); + return; + } + + // Save the template + await sarTemplateService.addTemplate(template); + + if (!context.mounted) return; + ToastLogger.success(context, AppLocalizations.of(context)!.templateSaved); + } catch (e) { + debugPrint('Error saving template: $e'); + if (!context.mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToSaveTemplate('$e'), + ); + } + } + + void _navigateToDrawing(BuildContext context) { + if (widget.message.drawingId == null) return; + widget.onTap?.call(); + } + + void _copyDrawingCoordinates(BuildContext context) { + if (widget.message.drawingId == null) return; + + final drawingProvider = context.read(); + final drawing = drawingProvider.getDrawingById(widget.message.drawingId!); + + if (drawing == null) { + ToastLogger.error(context, AppLocalizations.of(context)!.drawingNotFound); + return; + } + + // Format coordinates based on drawing type + String formatPoint(LatLng point) { + if (drawing.coordinateSpace == MapCoordinateSpace.customMap) { + return '${point.latitude.toStringAsFixed(0)}, ${point.longitude.toStringAsFixed(0)}'; + } + return '${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}'; + } + + String coordinatesText; + if (drawing is LineDrawing) { + coordinatesText = drawing.points.map(formatPoint).join('\n'); + } else if (drawing is RectangleDrawing) { + coordinatesText = drawing.corners.map(formatPoint).join('\n'); + } else { + ToastLogger.error( + context, + AppLocalizations.of(context)!.unknownDrawingType, + ); + return; + } + + Clipboard.setData(ClipboardData(text: coordinatesText)); + ToastLogger.success( + context, + AppLocalizations.of(context)!.textCopiedToClipboard, + ); + } + + void _showDeleteDrawingConfirmation(BuildContext context) { + if (widget.message.drawingId == null) return; + + final l10n = AppLocalizations.of(context)!; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.deleteDrawing), + content: Text( + '${l10n.deleteMessageConfirmation} This will also remove the drawing from the map.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () { + final drawingProvider = context.read(); + final messagesProvider = context.read(); + + // Remove the drawing from map and delete the message + drawingProvider.removeDrawingAndMessage( + widget.message.drawingId!, + messagesProvider, + ); + + Navigator.pop(context); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(l10n.delete), + ), + ], + ), + ); + } + + Color _getMessageBubbleColor( + BuildContext context, + bool isOwnMessage, + bool isDarkMode, + ) { + if (isOwnMessage) { + // Own messages: slightly highlighted with primary color tint + return isDarkMode + ? Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.3) + : Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.15); + } else { + // Others' messages: default surface color + return Theme.of(context).colorScheme.surfaceContainerHighest; + } + } + + Color _getSarMarkerColor(BuildContext context, bool isDarkMode) { + if (widget.message.sarMarkerType == null) { + return Theme.of(context).colorScheme.primaryContainer; + } + + // Use type-specific colors with alpha for background + switch (widget.message.sarMarkerType!) { + case SarMarkerType.foundPerson: + return isDarkMode + ? const Color(0xFF1B5E20).withValues(alpha: 0.4) + : const Color(0xFFC8E6C9).withValues(alpha: 0.9); + case SarMarkerType.fire: + return isDarkMode + ? const Color(0xFFB71C1C).withValues(alpha: 0.4) + : const Color(0xFFFFCDD2).withValues(alpha: 0.9); + case SarMarkerType.stagingArea: + return isDarkMode + ? const Color(0xFF0D47A1).withValues(alpha: 0.4) + : const Color(0xFFBBDEFB).withValues(alpha: 0.9); + case SarMarkerType.object: + return isDarkMode + ? const Color(0xFF4A148C).withValues(alpha: 0.4) + : const Color(0xFFE1BEE7).withValues(alpha: 0.9); + case SarMarkerType.unknown: + return isDarkMode + ? const Color(0xFF424242).withValues(alpha: 0.4) + : const Color(0xFFEEEEEE).withValues(alpha: 0.9); + } + } + + Color _getSarMarkerBorderColor(BuildContext context, bool isDarkMode) { + if (widget.message.sarMarkerType == null) { + return Theme.of(context).colorScheme.primary; + } + + // Use vibrant type-specific colors for borders + switch (widget.message.sarMarkerType!) { + case SarMarkerType.foundPerson: + return const Color(0xFF4CAF50); // Green + case SarMarkerType.fire: + return const Color(0xFFF44336); // Red + case SarMarkerType.stagingArea: + return const Color(0xFF2196F3); // Blue + case SarMarkerType.object: + return const Color(0xFF9C27B0); // Purple + case SarMarkerType.unknown: + return const Color(0xFF9E9E9E); // Gray + } + } + + String _hopDebugLabel(Message message) { + if (message.pathLen >= 255 && message.isContactMessage) { + return 'Direct (raw: ${message.pathLen})'; + } + if (message.pathLen >= 255) return 'Unknown (raw: ${message.pathLen})'; + return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}'; + } + + String? _retryCauseLabel(Message message) { + if (!message.isContactMessage || message.expectedAckTag == null) { + return null; + } + + if (message.deliveryStatus == MessageDeliveryStatus.sending && + message.retryAttempt == 0) { + return 'Waiting for delivery ACK'; + } + + if (message.retryAttempt > 0 || message.usedFloodFallback) { + return 'Delivery ACK timeout'; + } + + if (message.deliveryStatus == MessageDeliveryStatus.failed) { + return 'Delivery confirmation not received'; + } + + return null; + } + + String? _retryModeLabel(Message message) { + if (!message.isContactMessage) { + return null; + } + + final routeMetadata = context + .read() + .getMessageRouteMetadata(message.id); + if (routeMetadata != null) { + return routeMetadata.modeLabel; + } + + if (message.usedFloodFallback) { + return 'Flood route'; + } + + if (message.retryAttempt > 0 || message.expectedAckTag != null) { + return 'Learned direct path'; + } + + return null; + } + + String? _retryResultLabel(Message message) { + if (!message.isContactMessage) { + return null; + } + + final routeMetadata = context + .read() + .getMessageRouteMetadata(message.id); + final routeLabel = routeMetadata?.modeLabel.toLowerCase(); + + if (message.deliveryStatus == MessageDeliveryStatus.delivered) { + if (routeLabel != null) { + return 'Delivered via $routeLabel'; + } + if (message.usedFloodFallback) { + return 'Delivered after flood route'; + } + if (message.retryAttempt > 0) { + return 'Delivered after retry'; + } + if (message.expectedAckTag != null) { + return 'Delivery confirmed'; + } + } + + if (message.deliveryStatus == MessageDeliveryStatus.sending) { + if (routeLabel != null) { + return '$routeLabel in progress'; + } + if (message.usedFloodFallback) { + return 'Flood route in progress'; + } + if (message.retryAttempt > 0) { + return 'Retry in progress'; + } + if (message.expectedAckTag != null) { + return 'Awaiting confirmation'; + } + } + + if (message.deliveryStatus == MessageDeliveryStatus.failed) { + if (routeLabel != null) { + return 'Failed via $routeLabel'; + } + if (message.usedFloodFallback) { + return 'Failed after flood route'; + } + if (message.retryAttempt > 0) { + return 'Failed after retry attempts'; + } + return 'Delivery failed'; + } + + return null; + } + + Widget _buildDirectMessageStatusIndicator( + BuildContext context, + Message message, + ) { + switch (message.deliveryStatus) { + case MessageDeliveryStatus.delivered: + return Icon( + Icons.done_all, + size: 16, + color: Theme.of(context).colorScheme.primary, + ); + case MessageDeliveryStatus.sent: + return Icon( + Icons.done, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + case MessageDeliveryStatus.sending: + return Icon(Icons.schedule, size: 15, color: Colors.orange); + case MessageDeliveryStatus.failed: + return const Icon(Icons.error_outline, size: 15, color: Colors.red); + case MessageDeliveryStatus.received: + return Icon( + Icons.done_all, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + } + } + + @override + Widget build(BuildContext context) { + // Display system messages with minimal styling + if (widget.message.isSystemMessage) { + return SystemMessageBubble(message: widget.message); + } + + final message = widget.message; + final ticTacToeEvent = message.isContactMessage + ? TicTacToeMessageParser.tryParse(message.text) + : null; + if (ticTacToeEvent?.type == TicTacToeEventType.move) { + // Hide move control packets from chat; the game bubble updates itself. + return const SizedBox.shrink(); + } + final isSarMarker = message.isSarMarker; + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final messageFontScale = context.watch().messageFontScale; + final l10n = AppLocalizations.of(context)!; + + // Determine if this is own message + final connectionProvider = context.read(); + final messagesProvider = context.read(); + final channelsProvider = context.watch(); + final selfPublicKey = connectionProvider.deviceInfo.publicKey; + final isOwnMessage = + message.isSentMessage || message.isFromSelf(selfPublicKey); + final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id); + + // Look up contact information for rich display name + final contactsProvider = context.read(); + dynamic senderContact; + if (message.senderPublicKeyPrefix != null && !isOwnMessage) { + final senderKeyHex = message.senderPublicKeyPrefix! + .sublist( + 0, + message.senderPublicKeyPrefix!.length < 6 + ? message.senderPublicKeyPrefix!.length + : 6, + ) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + + senderContact = contactsProvider.contacts.where((c) { + return c.publicKeyHex.startsWith(senderKeyHex); + }).firstOrNull; + } + + // Get rich display name (with emoji if available) + final displayName = isOwnMessage + ? l10n.you + : message.getRichDisplayName(senderContact); + + // Look up destination/source display labels for direct/channel messages + dynamic recipientContact; + String? recipientDisplayName; + String? channelDisplayName; + if (isOwnMessage && + message.isContactMessage && + message.recipientPublicKey != null) { + final recipientKeyHex = message.recipientPublicKey! + .sublist( + 0, + message.recipientPublicKey!.length < 6 + ? message.recipientPublicKey!.length + : 6, + ) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + + recipientContact = contactsProvider.contacts.where((c) { + final matches = c.publicKeyHex.startsWith(recipientKeyHex); + return matches; + }).firstOrNull; + + if (recipientContact != null) { + final roleEmoji = recipientContact.roleEmoji; + if (roleEmoji != null && roleEmoji.isNotEmpty) { + recipientDisplayName = '$roleEmoji ${recipientContact.displayName}'; + } else { + recipientDisplayName = + recipientContact.displayName ?? recipientContact.advName; + } + } + } else if (message.isChannelMessage) { + final channelIdx = message.channelIdx ?? 0; + if (channelIdx == 0) { + channelDisplayName = l10n.publicChannel; + } else { + final channelContact = contactsProvider.channels.where((c) { + return c.publicKey.length > 1 && c.publicKey[1] == channelIdx; + }).firstOrNull; + final syncedChannel = channelsProvider.getChannel(channelIdx); + final syncedChannelDisplayName = + syncedChannel != null && syncedChannel.hasCustomName + ? syncedChannel.displayName + : null; + final contactChannelDisplayName = channelContact + ?.getLocalizedDisplayName(context) + .trim(); + + channelDisplayName = + syncedChannelDisplayName ?? + (contactChannelDisplayName != null && + contactChannelDisplayName.isNotEmpty + ? contactChannelDisplayName + : null) ?? + syncedChannel?.displayName ?? + '${l10n.channel} $channelIdx'; + } + + if (isOwnMessage) { + recipientDisplayName = channelDisplayName; + } + } + + final recipientSubtitle = + isOwnMessage && message.isChannelMessage && recipientDisplayName != null + ? '${l10n.channel}: $recipientDisplayName' + : recipientDisplayName; + final directCounterpartLabel = !message.isChannelMessage + ? (isOwnMessage ? recipientSubtitle : l10n.you) + : null; + final receivedChannelSubtitle = + !isOwnMessage && message.isChannelMessage && channelDisplayName != null + ? '${l10n.channel}: $channelDisplayName' + : null; + + final shouldFloatBubble = widget.isCompact; + final baseBodyStyle = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontSize: 14 * messageFontScale); + final bubble = ConstrainedBox( + constraints: BoxConstraints( + maxWidth: shouldFloatBubble + ? MediaQuery.of(context).size.width * 0.78 + : double.infinity, + ), + child: GestureDetector( + onTap: () => _handleBubbleTap( + isSarMarker: isSarMarker, + isDrawing: message.isDrawing, + ), + onDoubleTap: widget.isCompact + ? null + : () => _showTechnicalDetails(context), + onLongPress: widget.isCompact + ? null + : () => _showMessageOptions(context), + child: Container( + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: widget.isHighlighted + ? Theme.of(context).colorScheme.primaryContainer + : isSarMarker + ? _getSarMarkerColor(context, isDarkMode) + : message.isDrawing + ? (isDarkMode + ? Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15) + : Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.08)) + : _getMessageBubbleColor(context, isOwnMessage, isDarkMode), + borderRadius: BorderRadius.circular(12), + border: widget.isHighlighted + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 3, + ) + : isSarMarker + ? Border.all( + color: _getSarMarkerBorderColor(context, isDarkMode), + width: 2, + ) + : message.isDrawing + ? Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.4), + width: 2, + ) + : isOwnMessage + ? Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.3), + width: 1.5, + ) + : !message.isRead && + !message.isSentMessage && + !message.isSystemMessage + ? Border.all(color: Colors.blue, width: 1.5) + : null, + boxShadow: widget.isHighlighted + ? [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), + blurRadius: 12, + spreadRadius: 2, + offset: const Offset(0, 2), + ), + ] + : isSarMarker || message.isDrawing + ? [ + BoxShadow( + color: + (isSarMarker + ? _getSarMarkerBorderColor( + context, + isDarkMode, + ) + : Theme.of(context).colorScheme.primary) + .withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header badge for drawing messages + if (message.isDrawing) + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.draw, size: 16, color: Colors.white), + SizedBox(width: 4), + Text( + AppLocalizations.of(context)!.mapDrawing, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ], + ), + + // Sender info row (shown for all messages) + Row( + children: [ + if (isSarMarker) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: _getSarMarkerBorderColor(context, isDarkMode), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.warning_amber_rounded, + size: 16, + color: Colors.white, + ), + SizedBox(width: 4), + Text( + AppLocalizations.of(context)!.sarAlert, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + ], + ), + ), + const SizedBox(width: 8), + ], + // Unread indicator badge (only for regular messages, not SAR/drawing) + if (!message.isRead && + !message.isSentMessage && + !message.isSystemMessage && + !isSarMarker && + !message.isDrawing) + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: 8), + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + ), + buildMessageHeaderAvatar( + context, + isOwnMessage: isOwnMessage, + isChannelMessage: message.isChannelMessage, + senderContact: senderContact, + displayName: displayName, + ), + const SizedBox(width: 4), + Expanded( + child: Row( + children: [ + Expanded( + child: Text( + displayName, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + fontSize: 12, + fontWeight: FontWeight.bold, + height: 1.1, + color: isOwnMessage + ? Theme.of(context).colorScheme.primary + : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (!widget.isCompact && + (recipientSubtitle != null || + directCounterpartLabel != null || + receivedChannelSubtitle != null)) ...[ + const SizedBox(width: 8), + if (message.isChannelMessage) + Align( + alignment: Alignment.centerRight, + child: buildChannelHeaderPill( + context, + label: isOwnMessage + ? recipientDisplayName! + : channelDisplayName!, + ), + ) + else + Align( + alignment: Alignment.centerRight, + child: buildDirectHeaderCounterpart( + context, + label: directCounterpartLabel!, + ), + ), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 8), + + // SAR marker content + if (isSarMarker && message.sarMarkerType != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues( + alpha: isDarkMode ? 0.06 : 0.42, + ), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ).withValues(alpha: 0.22), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ).withValues(alpha: isDarkMode ? 0.2 : 0.12), + borderRadius: BorderRadius.circular(16), + ), + alignment: Alignment.center, + child: Text( + message.sarCustomEmoji ?? + message.sarMarkerType!.emoji, + style: const TextStyle(fontSize: 30, height: 1), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.sarMarkerType!.getLocalizedName( + context, + ), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + if (message.sarNotes != null && + message.sarNotes!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + message.sarNotes!, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + height: 1.25, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.86), + ), + ), + ], + ], + ), + ), + if (!widget.isCompact) + Padding( + padding: const EdgeInsets.only(left: 8, top: 2), + child: Icon( + Icons.chevron_right_rounded, + size: 20, + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ), + ), + ), + ], + ), + if (message.sarGpsCoordinates != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: isDarkMode ? 0.18 : 0.05, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.place_outlined, + size: 15, + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.tag_rounded, + size: 15, + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + formatPlusCode( + message.sarGpsCoordinates!.latitude, + message.sarGpsCoordinates!.longitude, + ), + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + ), + ), + ), + ], + ), + ], + ), + ), + ] else if (message.sarCustomMapPoint != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: isDarkMode ? 0.18 : 0.05, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.map_outlined, + size: 15, + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + AppLocalizations.of( + context, + )!.customMapMarker, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + AppLocalizations.of( + context, + )!.customMapPointLabel( + '${message.sarCustomMapPoint!.latitude.toStringAsFixed(0)}, ${message.sarCustomMapPoint!.longitude.toStringAsFixed(0)}', + ), + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + ), + ), + if (message.sarCustomMapId != null && + message.sarCustomMapId!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + AppLocalizations.of( + context, + )!.mapIdLabel(message.sarCustomMapId!), + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + ), + ), + ], + ], + ), + ), + ], + ], + ), + ), + ] + // Drawing message content (skip in compact mode - drawings hidden) + else if (message.isDrawing && + message.drawingId != null && + !widget.isCompact) + Consumer( + builder: (context, drawingProvider, child) { + final drawing = drawingProvider.getDrawingById( + message.drawingId!, + ); + + if (drawing == null) { + return _buildMessageTextContent( + message.text, + baseBodyStyle, + ); + } + + final String drawingTypeLabel; + if (drawing is LineDrawing) { + drawingTypeLabel = AppLocalizations.of( + context, + )!.lineDrawing; + } else if (drawing is RectangleDrawing) { + drawingTypeLabel = AppLocalizations.of( + context, + )!.rectangleDrawing; + } else { + drawingTypeLabel = AppLocalizations.of(context)!.drawing; + } + + final colorName = DrawingColors.colorToName(drawing.color); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DrawingMinimapPreview(drawing: drawing), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drawingTypeLabel, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: drawing.color, + shape: BoxShape.circle, + border: Border.all( + color: Colors.black26, + width: 1, + ), + ), + ), + const SizedBox(width: 6), + Text( + colorName, + style: Theme.of( + context, + ).textTheme.labelSmall, + ), + ], + ), + ], + ), + ), + Icon( + Icons.chevron_right, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ], + ); + }, + ) + // Voice message content + else if (message.isVoice && + message.voiceId != null && + !widget.isCompact) + VoiceMessageBubble(message: message, isSentByMe: isOwnMessage) + // Image message content (IE1 envelope) + else if (ImageEnvelope.isEnvelope(message.text) && + !widget.isCompact) + ImageMessageBubble(message: message, isSentByMe: isOwnMessage) + // Tic-Tac-Toe control message content + else if (ticTacToeEvent?.type == TicTacToeEventType.start && + !widget.isCompact) + TicTacToeMessageBubble( + message: message, + isSentByMe: isOwnMessage, + ) + // Regular message content + else if (!message.isDrawing || widget.isCompact) + _buildMessageTextContent(message.text, baseBodyStyle), + + // Delivery status for sent messages (skip in compact mode) + if (message.isSentMessage && !widget.isCompact) ...[ + const SizedBox(height: 6), + // Debug: Log grouped message detection + Builder( + builder: (context) { + if (message.isGroupedMessage) { + debugPrint( + '🎯 [MessageBubble] Rendering grouped message: ${message.id}', + ); + debugPrint( + ' Recipients: ${message.recipients?.length ?? 0}', + ); + debugPrint( + ' Delivered: ${message.deliveredRecipientsCount}', + ); + debugPrint(' Failed: ${message.failedRecipientsCount}'); + } + return const SizedBox.shrink(); + }, + ), + // Show grouped message delivery count + if (message.isGroupedMessage) ...[ + GestureDetector( + onTap: _toggleExpanded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + message.deliveredRecipientsCount == + message.recipients!.length + ? Icons.done_all + : message.failedRecipientsCount > 0 + ? Icons.error_outline + : Icons.schedule, + size: 12, + color: + message.deliveredRecipientsCount == + message.recipients!.length + ? Colors.green + : message.failedRecipientsCount > 0 + ? Colors.red + : Colors.orange, + ), + SizedBox(width: 3), + Text( + message.deliveredRecipientsCount == + message.recipients!.length + ? AppLocalizations.of(context)!.allDelivered + : AppLocalizations.of( + context, + )!.deliveredToContacts( + message.deliveredRecipientsCount, + message.recipients!.length, + ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: + message.deliveredRecipientsCount == + message.recipients!.length + ? Colors.green + : message.failedRecipientsCount > 0 + ? Colors.red + : Colors.orange, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(width: 4), + Icon( + _isExpanded + ? Icons.expand_less + : Icons.expand_more, + size: 14, + color: Theme.of( + context, + ).textTheme.labelSmall?.color, + ), + ], + ), + // Expandable recipient details + if (_isExpanded) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of( + context, + )!.recipientDetails, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + ...message.recipients!.map((recipient) { + final Color statusColor; + final IconData statusIcon; + final String statusText; + + switch (recipient.deliveryStatus) { + case MessageDeliveryStatus.delivered: + statusColor = Colors.green; + statusIcon = Icons.done_all; + statusText = + recipient.roundTripTimeMs != null + ? '${recipient.roundTripTimeMs}ms' + : AppLocalizations.of( + context, + )!.delivered; + break; + case MessageDeliveryStatus.sent: + statusColor = Theme.of( + context, + ).colorScheme.onSurfaceVariant; + statusIcon = Icons.done; + statusText = AppLocalizations.of( + context, + )!.sent; + break; + case MessageDeliveryStatus.failed: + statusColor = Colors.red; + statusIcon = Icons.cancel; + statusText = AppLocalizations.of( + context, + )!.failed; + break; + case MessageDeliveryStatus.sending: + default: + statusColor = Colors.orange; + statusIcon = Icons.schedule; + statusText = AppLocalizations.of( + context, + )!.pending; + } + + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Icon( + statusIcon, + size: 14, + color: statusColor, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + recipient.displayName, + style: Theme.of( + context, + ).textTheme.labelSmall, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + statusText, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + }), + ], + ), + ), + ], + ], + ), + ), + ] + // Show single message delivery status + // Channel messages only show transient states (sending/failed); + // delivered/ACK states don't exist for channels + else if (!message.isChannelMessage || + message.deliveryStatus == MessageDeliveryStatus.sending || + message.deliveryStatus == MessageDeliveryStatus.failed) + Row( + mainAxisSize: MainAxisSize.max, + children: [ + if (message.isContactMessage) + _buildDirectMessageStatusIndicator(context, message) + else ...[ + Icon( + getDeliveryStatusIcon(message.deliveryStatus), + size: 12, + color: getDeliveryStatusColor(message.deliveryStatus), + ), + const SizedBox(width: 3), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + message.getLocalizedDeliveryStatus(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: getDeliveryStatusColor( + message.deliveryStatus, + ), + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ], + // Show retry button for failed messages + if (message.deliveryStatus == + MessageDeliveryStatus.failed) ...[ + const SizedBox(width: 6), + GestureDetector( + onTap: () => _retryFailedMessage(context, message), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.orange, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.refresh, + size: 12, + color: Colors.orange, + ), + const SizedBox(width: 4), + Text( + 'Retry', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ], + ], + ), + ), + ), + ); + + final bubbleWithMeta = Column( + crossAxisAlignment: isOwnMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + bubble, + buildBubbleMetaFooter( + context, + message: message, + isSarMarker: isSarMarker, + routeMetadata: routeMetadata, + ), + ], + ); + + if (!shouldFloatBubble) { + return bubbleWithMeta; + } + + return Row( + mainAxisAlignment: isOwnMessage + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [Flexible(child: bubbleWithMeta)], + ); + } +} diff --git a/lib/widgets/messages/message_bubble_header.dart b/lib/widgets/messages/message_bubble_header.dart new file mode 100644 index 0000000..edf5f86 --- /dev/null +++ b/lib/widgets/messages/message_bubble_header.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; + +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../../models/message_route_metadata.dart'; +import '../../utils/avatar_label_helper.dart'; +import '../../utils/message_extensions.dart'; +import '../common/contact_avatar.dart'; + +Widget buildMessageHeaderAvatar( + BuildContext context, { + required bool isOwnMessage, + required bool isChannelMessage, + required dynamic senderContact, + required String displayName, +}) { + if (isOwnMessage) { + return CircleAvatar( + radius: 10.5, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon( + Icons.account_circle, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ); + } + + if (senderContact is Contact) { + return ContactAvatar( + contact: senderContact, + radius: 10.5, + displayName: displayName, + ); + } + + final background = isChannelMessage + ? Colors.teal.withValues(alpha: 0.16) + : Theme.of(context).colorScheme.surfaceContainerHighest; + final foreground = isChannelMessage + ? Colors.teal.shade800 + : Theme.of(context).colorScheme.onSurfaceVariant; + + return CircleAvatar( + radius: 10.5, + backgroundColor: background, + child: Text( + AvatarLabelHelper.buildLabel(displayName), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: foreground, + letterSpacing: -0.2, + ), + ), + ); +} + +Widget buildBubbleMetaFooter( + BuildContext context, { + required Message message, + required bool isSarMarker, + MessageRouteMetadata? routeMetadata, +}) { + final metaColor = Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.68); + + final items = []; + final sentEchoLabel = message.isSentMessage && message.echoCount > 0 + ? '${message.echoCount} echo${message.echoCount == 1 ? '' : 'es'}' + : null; + + if (!isSarMarker && sentEchoLabel != null) { + items.addAll([ + Icon(Icons.hub_outlined, size: 11, color: metaColor), + const SizedBox(width: 3), + Text( + sentEchoLabel, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + Text( + ' • ', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + ]); + } else if (!isSarMarker) { + final routeLabel = _effectiveRouteFooterLabel(message, routeMetadata); + if (routeLabel != null) { + items.addAll([ + Icon(Icons.alt_route, size: 11, color: metaColor), + const SizedBox(width: 3), + Text( + routeLabel, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + Text( + ' • ', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: metaColor), + ), + ]); + } + } + + items.add( + Text( + message.getLocalizedTimeAgo(context), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: metaColor, + fontWeight: FontWeight.w500, + ), + ), + ); + + return Padding( + padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18), + child: Align( + alignment: Alignment.centerRight, + child: Row(mainAxisSize: MainAxisSize.min, children: items), + ), + ); +} + +String? _effectiveRouteFooterLabel( + Message message, + MessageRouteMetadata? routeMetadata, +) { + if (routeMetadata?.mode.name == 'flood') { + return 'flood'; + } + + final effectivePathLen = _effectivePathLen(message, routeMetadata); + if (effectivePathLen >= 255) { + return null; + } + + return effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop'; +} + +int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) { + if (routeMetadata?.hopCount != null) { + return routeMetadata!.hopCount!; + } + if (routeMetadata?.mode.name == 'flood') { + return 255; + } + return message.pathLen; +} + +Widget buildChannelHeaderPill( + BuildContext context, { + required String label, + IconData icon = Icons.campaign_outlined, + EdgeInsetsGeometry padding = const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + double iconSize = 11, + double iconSpacing = 5, + TextStyle? textStyle, +}) { + final labelColor = Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.82); + + return Container( + padding: padding, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: iconSize, + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.7), + ), + SizedBox(width: iconSpacing), + Flexible( + child: Text( + label, + style: + textStyle ?? + Theme.of(context).textTheme.labelSmall?.copyWith( + color: labelColor, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); +} + +Widget buildDirectHeaderCounterpart( + BuildContext context, { + required String label, +}) { + return buildChannelHeaderPill( + context, + label: label, + icon: Icons.alternate_email, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + iconSize: 10, + iconSpacing: 4, + textStyle: Theme.of(context).textTheme.labelSmall?.copyWith( + fontSize: 10, + height: 1.0, + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.82), + fontWeight: FontWeight.w600, + ), + ); +} diff --git a/lib/widgets/messages/message_bubble_signal.dart b/lib/widgets/messages/message_bubble_signal.dart new file mode 100644 index 0000000..f2bd234 --- /dev/null +++ b/lib/widgets/messages/message_bubble_signal.dart @@ -0,0 +1,386 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/message.dart'; +import '../../models/message_route_metadata.dart'; +import '../../models/path_selection.dart'; +import '../../models/message_reception_details.dart'; +import '../../providers/messages_provider.dart'; +import '../../utils/link_quality.dart'; + +IconData getDeliveryStatusIcon(MessageDeliveryStatus status) { + switch (status) { + case MessageDeliveryStatus.sending: + return Icons.schedule; + case MessageDeliveryStatus.sent: + return Icons.done; + case MessageDeliveryStatus.delivered: + return Icons.done_all; + case MessageDeliveryStatus.failed: + return Icons.error_outline; + case MessageDeliveryStatus.received: + return Icons.inbox; + } +} + +Color getDeliveryStatusColor(MessageDeliveryStatus status) { + switch (status) { + case MessageDeliveryStatus.sending: + return Colors.orange; + case MessageDeliveryStatus.sent: + return Colors.blue; + case MessageDeliveryStatus.delivered: + return Colors.green; + case MessageDeliveryStatus.failed: + return Colors.red; + case MessageDeliveryStatus.received: + return Colors.grey; + } +} + +Widget buildChannelEchoStatus(BuildContext context, Message message) { + final hasEcho = message.echoCount > 0; + + if (!hasEcho) { + return const SizedBox.shrink(); + } + + final statusColor = getDeliveryStatusColor(message.deliveryStatus); + final rssi = message.lastEchoRssiDbm; + final snr = message.lastEchoSnrRaw != null + ? message.lastEchoSnrRaw!.toSigned(8) / 4.0 + : null; + final quality = linkQualityLabel(rssi, snr); + final qualityColor = linkQualityColor(quality); + + return Wrap( + spacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _techChip( + context, + icon: Icons.hub_outlined, + label: 'x${message.echoCount}', + color: statusColor, + ), + if (message.expectedAckTag != null) + _techChip( + context, + icon: Icons.tag, + label: + 'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}', + color: Colors.indigo, + ), + _techChip(context, icon: Icons.bolt, label: quality, color: qualityColor), + if (message.lastEchoRssiDbm != null) + _signalCapsule( + context, + icon: Icons.network_cell, + label: message.lastEchoRssiDbm!.toString(), + filled: rssiScore(message.lastEchoRssiDbm!), + color: Colors.blueGrey, + ), + if (message.lastEchoSnrRaw != null) + _signalCapsule( + context, + icon: Icons.graphic_eq, + label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(1), + filled: snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0), + color: Colors.teal, + ), + ], + ); +} + +bool shouldShowSentChannelStats(Message message) { + if (!message.isSentMessage || !message.isChannelMessage) { + return false; + } + + final hasSignalData = + message.echoCount > 0 || + message.lastEchoRssiDbm != null || + message.lastEchoSnrRaw != null || + message.expectedAckTag != null; + return hasSignalData; +} + +Widget buildReceivedSignalStatus( + BuildContext context, + Message message, { + MessageReceptionDetails? receptionDetails, + required int? rssiDbm, + required double? snrDb, +}) { + final hopLabel = hopDisplayLabel(context, message); + + return Wrap( + spacing: 4, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _techChip( + context, + icon: Icons.alt_route, + label: hopLabel, + color: Colors.indigo, + ), + if (receptionDetails?.senderToReceiptMs != null) + _techChip( + context, + icon: Icons.schedule, + label: _formatMs(receptionDetails!.senderToReceiptMs!), + color: Colors.deepPurple, + ), + if (receptionDetails?.estimatedTransmitMs != null) + _techChip( + context, + icon: Icons.timelapse, + label: '~${_formatMs(receptionDetails!.estimatedTransmitMs!)} tx', + color: Colors.blue, + ), + if (receptionDetails?.postTransmitDelayMs != null) + _techChip( + context, + icon: Icons.hourglass_bottom, + label: '+${_formatMs(receptionDetails!.postTransmitDelayMs!)} lag', + color: Colors.orange, + ), + if (receptionDetails?.pathBytesHex != null) + _techChip( + context, + icon: Icons.route, + label: receptionDetails!.pathBytesHex!, + color: Colors.brown, + ), + if (rssiDbm != null || snrDb != null) ...[ + _techChip( + context, + icon: Icons.bolt, + label: linkQualityLabel(rssiDbm, snrDb), + color: linkQualityColor(linkQualityLabel(rssiDbm, snrDb)), + ), + if (rssiDbm != null) + _signalCapsule( + context, + icon: Icons.network_cell, + label: '$rssiDbm', + filled: rssiScore(rssiDbm), + color: Colors.blueGrey, + ), + if (snrDb != null) + _signalCapsule( + context, + icon: Icons.graphic_eq, + label: snrDb.toStringAsFixed(1), + filled: snrScore(snrDb), + color: Colors.teal, + ), + ], + ], + ); +} + +Widget buildSentDirectSignalStatus( + BuildContext context, + Message message, { + required int roundTripTimeMs, + required Duration txEstimate, +}) { + final routeMetadata = context + .read() + .getMessageRouteMetadata(message.id); + final estimatedTransmitMs = sanitizeEstimatedTransmitMs( + estimatedTransmitMs: txEstimate > Duration.zero + ? txEstimate.inMilliseconds + : null, + senderToReceiptMs: roundTripTimeMs, + ); + final postTransmitDelayMs = estimatedTransmitMs != null + ? (roundTripTimeMs - estimatedTransmitMs).clamp(0, 86400000).toInt() + : null; + + return Wrap( + spacing: 4, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _techChip( + context, + icon: Icons.alt_route, + label: hopDisplayLabelForMessage(context, message, routeMetadata), + color: Colors.indigo, + ), + _techChip( + context, + icon: Icons.schedule, + label: _formatMs(roundTripTimeMs), + color: Colors.deepPurple, + ), + if (estimatedTransmitMs != null) + _techChip( + context, + icon: Icons.timelapse, + label: '~${_formatMs(estimatedTransmitMs)} tx', + color: Colors.blue, + ), + if (postTransmitDelayMs != null) + _techChip( + context, + icon: Icons.hourglass_bottom, + label: '+${_formatMs(postTransmitDelayMs)} lag', + color: Colors.orange, + ), + if (message.retryAttempt > 0) + _techChip( + context, + icon: Icons.refresh, + label: 'retry ${message.retryAttempt}/4', + color: Colors.redAccent, + ), + if (message.suggestedTimeoutMs != null) + _techChip( + context, + icon: Icons.timer_outlined, + label: 'timeout ${_formatMs(message.suggestedTimeoutMs!)}', + color: Colors.blueGrey, + ), + if (message.usedFloodFallback) + _techChip( + context, + icon: Icons.waves, + label: 'flood route', + color: Colors.teal, + ) + else if (message.expectedAckTag != null) + _techChip( + context, + icon: Icons.route, + label: 'direct ACK', + color: Colors.indigo, + ), + if (routeMetadata != null) + _techChip( + context, + icon: routeMetadata.mode == PathSelectionMode.nearestRouter + ? Icons.router + : Icons.alt_route, + label: routeMetadata.modeLabel, + color: routeMetadata.mode == PathSelectionMode.nearestRouter + ? Colors.deepPurple + : Colors.indigo, + ), + ], + ); +} + +String _formatMs(int value) { + if (value >= 60000) { + final minutes = value ~/ 60000; + final seconds = (value % 60000) ~/ 1000; + return '${minutes}m ${seconds}s'; + } + if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)}s'; + } + return '${value}ms'; +} + +String hopDisplayLabel(BuildContext context, Message message) { + final l10n = AppLocalizations.of(context)!; + if (message.pathLen == 0) return l10n.direct; + if (message.pathLen >= 255 && message.isContactMessage) return l10n.direct; + if (message.pathLen >= 255) return l10n.unknown; + return l10n.hopCount(message.pathLen); +} + +String hopDisplayLabelForMessage( + BuildContext context, + Message message, + MessageRouteMetadata? routeMetadata, +) { + final l10n = AppLocalizations.of(context)!; + final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen; + if (effectivePathLen == 0) return l10n.direct; + if (effectivePathLen >= 255 && message.isContactMessage) return l10n.direct; + if (effectivePathLen >= 255) return l10n.unknown; + return l10n.hopCount(effectivePathLen); +} + +Widget _techChip( + BuildContext context, { + required IconData icon, + required String label, + required Color color, +}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 10, color: color), + const SizedBox(width: 2), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ], + ), + ); +} + +Widget _signalCapsule( + BuildContext context, { + required IconData icon, + required String label, + required int filled, + required Color color, +}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 10, color: color), + const SizedBox(width: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (i) { + final active = i < filled; + return Container( + width: 3, + height: (4 + i).toDouble(), + margin: const EdgeInsets.symmetric(horizontal: 0.5), + decoration: BoxDecoration( + color: active ? color : color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(1), + ), + ); + }), + ), + const SizedBox(width: 2), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ], + ), + ); +} diff --git a/lib/widgets/messages/messages_composer.dart b/lib/widgets/messages/messages_composer.dart new file mode 100644 index 0000000..3e0a07b --- /dev/null +++ b/lib/widgets/messages/messages_composer.dart @@ -0,0 +1,668 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; +import '../../providers/app_provider.dart'; +import '../common/contact_avatar.dart'; + +class MessagesComposer extends StatelessWidget { + final TextEditingController textController; + final FocusNode focusNode; + final TextInputFormatter messageByteLimiter; + final int messageByteCount; + final int maxMessageBytes; + final bool isRecording; + final bool isSendingVoice; + final bool voiceSupported; + final double bottomPadding; + final String destinationLabel; + final Widget destinationAvatar; + final bool destinationLocked; + final List mentionSuggestions; + final String mentionQuery; + final ValueChanged onMentionSelected; + final VoidCallback onShowComposerActions; + final VoidCallback onShowRecipientSelector; + final Future Function() onStartVoiceRecording; + final Future Function() onStopAndSendVoice; + final Future Function() onCancelVoiceRecording; + final Future Function() onSendMessage; + final VoidCallback? onLongPressSend; + final String? regionScopeName; + final VoidCallback? onRegionScopeTap; + + const MessagesComposer({ + super.key, + required this.textController, + required this.focusNode, + required this.messageByteLimiter, + required this.messageByteCount, + required this.maxMessageBytes, + required this.isRecording, + required this.isSendingVoice, + required this.voiceSupported, + required this.bottomPadding, + required this.destinationLabel, + required this.destinationAvatar, + required this.destinationLocked, + required this.mentionSuggestions, + required this.mentionQuery, + required this.onMentionSelected, + required this.onShowComposerActions, + required this.onShowRecipientSelector, + required this.onStartVoiceRecording, + required this.onStopAndSendVoice, + required this.onCancelVoiceRecording, + required this.onSendMessage, + this.onLongPressSend, + this.regionScopeName, + this.onRegionScopeTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration(color: Colors.transparent), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (mentionSuggestions.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 6), + child: _MentionSuggestionsCard( + suggestions: mentionSuggestions, + query: mentionQuery, + onSelected: onMentionSelected, + ), + ), + SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(10, 4, 10, bottomPadding), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: Theme.of( + context, + ).dividerColor.withValues(alpha: 0.35), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 6), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _ComposerActionButton( + isRecording: isRecording, + onPressed: isRecording + ? onStopAndSendVoice + : onShowComposerActions, + ), + if (isRecording) ...[ + const SizedBox(width: 6), + _CancelRecordingButton( + onPressed: onCancelVoiceRecording, + ), + ], + if (regionScopeName != null && + onRegionScopeTap != null) ...[ + const SizedBox(width: 6), + _RegionScopeChip( + name: regionScopeName!, + onTap: onRegionScopeTap!, + ), + ], + const SizedBox(width: 8), + Expanded( + child: _DestinationPill( + destinationLabel: destinationLabel, + destinationAvatar: destinationAvatar, + isLocked: destinationLocked, + onTap: destinationLocked + ? null + : onShowRecipientSelector, + ), + ), + ], + ), + const SizedBox(height: 6), + ListenableBuilder( + listenable: Listenable.merge([ + textController, + focusNode, + ]), + builder: (context, _) { + final canSendText = + !isRecording && + !isSendingVoice && + textController.text.trim().isNotEmpty; + final l10n = AppLocalizations.of(context)!; + final semanticsLabel = isRecording + ? l10n.recordingReleaseToSend + : (isSendingVoice + ? l10n.sendingVoice + : voiceSupported + ? l10n.sendLongPressToRecordVoice + : l10n.send); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: _MessageInput( + textController: textController, + focusNode: focusNode, + messageByteLimiter: messageByteLimiter, + ), + ), + const SizedBox(width: 8), + _SendButton( + canSendText: canSendText, + isRecording: isRecording, + isSendingVoice: isSendingVoice, + voiceSupported: voiceSupported, + semanticsLabel: semanticsLabel, + messageByteCount: messageByteCount, + maxMessageBytes: maxMessageBytes, + onSendMessage: onSendMessage, + onLongPressSend: onLongPressSend, + onStartVoiceRecording: onStartVoiceRecording, + onStopAndSendVoice: onStopAndSendVoice, + onCancelVoiceRecording: onCancelVoiceRecording, + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _MentionSuggestionsCard extends StatelessWidget { + final List suggestions; + final String query; + final ValueChanged onSelected; + + const _MentionSuggestionsCard({ + required this.suggestions, + required this.query, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final visibleCount = suggestions.length.clamp(1, 4); + final maxHeight = visibleCount * 54.0; + + return Material( + elevation: 8, + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(18), + child: Container( + constraints: BoxConstraints(maxHeight: maxHeight), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.30), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + itemCount: suggestions.length, + separatorBuilder: (_, _) => const SizedBox(height: 2), + itemBuilder: (context, index) { + final contact = suggestions[index]; + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => onSelected(contact), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + child: Row( + children: [ + ContactAvatar(contact: contact, radius: 16), + const SizedBox(width: 10), + Expanded( + child: Text( + contact.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _ComposerActionButton extends StatelessWidget { + final bool isRecording; + final VoidCallback onPressed; + + const _ComposerActionButton({ + required this.isRecording, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.35), + ), + ), + child: IconButton( + icon: Icon(isRecording ? Icons.stop : Icons.add, size: 20), + tooltip: isRecording + ? AppLocalizations.of(context)!.stopRecording + : AppLocalizations.of(context)!.moreActions, + onPressed: onPressed, + color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary, + ), + ); + } +} + +class _CancelRecordingButton extends StatelessWidget { + final Future Function() onPressed; + + const _CancelRecordingButton({required this.onPressed}); + + @override + Widget build(BuildContext context) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.35), + ), + ), + child: IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + tooltip: AppLocalizations.of(context)!.discardRecording, + onPressed: onPressed, + color: Theme.of(context).colorScheme.error, + ), + ); + } +} + +class _DestinationPill extends StatelessWidget { + final String destinationLabel; + final Widget destinationAvatar; + final bool isLocked; + final VoidCallback? onTap; + + const _DestinationPill({ + required this.destinationLabel, + required this.destinationAvatar, + required this.isLocked, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final content = Ink( + key: ValueKey( + isLocked + ? 'messages_composer_destination_locked' + : 'messages_composer_destination_selector', + ), + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.35), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + destinationAvatar, + const SizedBox(width: 8), + Expanded( + child: Text( + destinationLabel, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + if (!isLocked) + Icon( + Icons.expand_more_rounded, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + + if (onTap == null) { + return Material(color: Colors.transparent, child: content); + } + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: content, + ), + ); + } +} + +class _MessageInput extends StatelessWidget { + final TextEditingController textController; + final FocusNode focusNode; + final TextInputFormatter messageByteLimiter; + + const _MessageInput({ + required this.textController, + required this.focusNode, + required this.messageByteLimiter, + }); + + @override + Widget build(BuildContext context) { + final messageFontScale = context.watch().messageFontScale; + const baseFontSize = 15.0; + final resolvedFontSize = baseFontSize * messageFontScale; + + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + constraints: const BoxConstraints(minHeight: 42, maxHeight: 116), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: focusNode.hasFocus + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor.withValues(alpha: 0.35), + width: focusNode.hasFocus ? 1.4 : 1, + ), + boxShadow: focusNode.hasFocus + ? [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.10), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: TextField( + controller: textController, + focusNode: focusNode, + minLines: 1, + maxLines: 4, + keyboardType: TextInputType.multiline, + inputFormatters: [messageByteLimiter], + style: TextStyle(fontSize: resolvedFontSize), + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.typeYourMessage, + hintStyle: TextStyle( + fontSize: resolvedFontSize, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.9), + ), + filled: false, + fillColor: Colors.transparent, + border: InputBorder.none, + isCollapsed: true, + ), + textInputAction: TextInputAction.newline, + ), + ), + ); + } +} + +class _SendButton extends StatelessWidget { + final bool canSendText; + final bool isRecording; + final bool isSendingVoice; + final bool voiceSupported; + final String semanticsLabel; + final int messageByteCount; + final int maxMessageBytes; + final Future Function() onSendMessage; + final VoidCallback? onLongPressSend; + final Future Function() onStartVoiceRecording; + final Future Function() onStopAndSendVoice; + final Future Function() onCancelVoiceRecording; + + const _SendButton({ + required this.canSendText, + required this.isRecording, + required this.isSendingVoice, + required this.voiceSupported, + required this.semanticsLabel, + required this.messageByteCount, + required this.maxMessageBytes, + required this.onSendMessage, + this.onLongPressSend, + required this.onStartVoiceRecording, + required this.onStopAndSendVoice, + required this.onCancelVoiceRecording, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + enabled: canSendText || (voiceSupported && !isSendingVoice), + label: semanticsLabel, + onTap: canSendText ? onSendMessage : null, + onLongPress: canSendText && onLongPressSend != null + ? onLongPressSend + : (voiceSupported && !isSendingVoice) + ? () { + if (isRecording) { + onStopAndSendVoice(); + return; + } + onStartVoiceRecording(); + } + : null, + child: Tooltip( + message: semanticsLabel, + excludeFromSemantics: true, + child: GestureDetector( + excludeFromSemantics: true, + onTap: canSendText ? onSendMessage : null, + onLongPressStart: canSendText && onLongPressSend != null + ? (_) => onLongPressSend!() + : (voiceSupported && !isSendingVoice) + ? (_) => onStartVoiceRecording() + : null, + onLongPressEnd: (!canSendText && voiceSupported && isRecording) + ? (_) => onStopAndSendVoice() + : null, + // Finger slid off the button: discard the recording instead of + // sending it. + onLongPressCancel: (!canSendText && voiceSupported && isRecording) + ? onCancelVoiceRecording + : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 42, + height: 42, + decoration: BoxDecoration( + color: canSendText || isRecording + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: canSendText || isRecording + ? Colors.transparent + : Theme.of( + context, + ).dividerColor.withValues(alpha: 0.35), + ), + boxShadow: canSendText || isRecording + ? [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.22), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ] + : null, + ), + child: isSendingVoice + ? Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onPrimary, + ), + ) + : Icon( + isRecording ? Icons.mic_rounded : Icons.send_rounded, + size: 20, + color: canSendText || isRecording + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + '$messageByteCount/$maxMessageBytes', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: messageByteCount > maxMessageBytes * 0.9 + ? Colors.orange.shade800 + : Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.9), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RegionScopeChip extends StatelessWidget { + final String name; + final VoidCallback onTap; + + const _RegionScopeChip({required this.name, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.tertiary.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.language_rounded, + size: 16, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 80), + child: Text( + name, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: colorScheme.onTertiaryContainer, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/messages/messages_content.dart b/lib/widgets/messages/messages_content.dart new file mode 100644 index 0000000..1daf9df --- /dev/null +++ b/lib/widgets/messages/messages_content.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/message.dart'; +import '../../providers/messages_provider.dart'; +import '../common/bidirectional_refresh.dart'; +import '../../widgets/messages/message_bubble.dart'; + +class MessagesContent extends StatelessWidget { + static const double defaultPadding = 8; + + final List messages; + final ScrollController scrollController; + final String? highlightedMessageId; + final double bottomContentPadding; + final Future Function() onRefresh; + final VoidCallback? onNavigateToMap; + final ValueChanged? onMessageTap; + final ValueChanged? onReplyToMessage; + + const MessagesContent({ + super.key, + required this.messages, + required this.scrollController, + required this.highlightedMessageId, + this.bottomContentPadding = 0, + required this.onRefresh, + this.onNavigateToMap, + this.onMessageTap, + this.onReplyToMessage, + }); + + @override + Widget build(BuildContext context) { + const scrollPhysics = BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ); + + return BidirectionalRefresh( + onRefresh: onRefresh, + child: messages.isEmpty + ? LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + physics: scrollPhysics, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.message_outlined, + size: 64, + color: Theme.of(context).disabledColor, + ), + SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.noMessagesYet, + style: Theme.of(context).textTheme.titleLarge, + ), + SizedBox(height: 8), + Text( + AppLocalizations.of(context)!.pullDownToSync, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ) + : ListView.builder( + controller: scrollController, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + physics: scrollPhysics, + reverse: true, + padding: EdgeInsets.fromLTRB( + defaultPadding, + defaultPadding, + defaultPadding, + defaultPadding + bottomContentPadding, + ), + itemCount: messages.length, + itemBuilder: (context, index) { + final entry = messages[index]; + final message = entry.message; + + return MessageBubble( + key: ValueKey(message.id), + message: message, + receivedCopies: entry.occurrenceCount, + isHighlighted: message.id == highlightedMessageId, + onNavigateToMap: onNavigateToMap, + onTap: onMessageTap == null + ? null + : () => onMessageTap!(message), + onReply: onReplyToMessage == null + ? null + : () => onReplyToMessage!(message), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart new file mode 100644 index 0000000..426b7d8 --- /dev/null +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -0,0 +1,1197 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; +import '../../providers/app_provider.dart'; +import '../../providers/messages_provider.dart'; +import '../../utils/avatar_label_helper.dart'; +import '../common/contact_avatar.dart'; + +enum _RecipientSortMode { activity, favorites, alphabetical } + +/// Bottom sheet for selecting message recipient (channel, contact, or room) +class RecipientSelectorSheet extends StatefulWidget { + final List contacts; + final List rooms; + final List channels; + final int unreadCount; + final Map unreadCountsByPublicKey; + final String? currentDestinationType; + final String? currentRecipientPublicKey; + final bool showAllOption; + final Function(String type, Contact? recipient) onSelect; + final MessagesProvider? messagesProvider; + + /// Region scope names per channel index (e.g. {0: "#auckland"}). + final Map channelRegionScopes; + + const RecipientSelectorSheet({ + super.key, + required this.contacts, + required this.rooms, + required this.channels, + required this.unreadCount, + required this.unreadCountsByPublicKey, + this.currentDestinationType, + this.currentRecipientPublicKey, + this.showAllOption = true, + required this.onSelect, + this.messagesProvider, + this.channelRegionScopes = const {}, + }); + + @override + State createState() => _RecipientSelectorSheetState(); +} + +class _RecipientSelectorSheetState extends State { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + _RecipientSortMode _sortMode = _RecipientSortMode.activity; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + int _unreadFor(Contact? contact) { + if (contact == null) { + return widget.unreadCount; + } + + return widget.unreadCountsByPublicKey[contact.publicKeyHex] ?? 0; + } + + List _filterAndSortContacts( + List contacts, { + bool prioritizePublicChannel = false, + }) { + final normalizedQuery = _searchQuery.trim().toLowerCase(); + final filtered = contacts.where((contact) { + if (normalizedQuery.isEmpty) { + return true; + } + + return contact.displayName.toLowerCase().contains(normalizedQuery) || + contact.advName.toLowerCase().contains(normalizedQuery) || + contact.publicKeyShort.toLowerCase().contains(normalizedQuery); + }).toList(); + + filtered.sort((a, b) { + if (prioritizePublicChannel && a.isPublicChannel != b.isPublicChannel) { + return a.isPublicChannel ? -1 : 1; + } + + if (_sortMode == _RecipientSortMode.favorites) { + if (a.isFavourite != b.isFavourite) { + return a.isFavourite ? -1 : 1; + } + final unreadCompare = _unreadFor(b).compareTo(_unreadFor(a)); + if (unreadCompare != 0) { + return unreadCompare; + } + final lastSeenCompare = b.lastSeenTime.compareTo(a.lastSeenTime); + if (lastSeenCompare != 0) { + return lastSeenCompare; + } + } else if (_sortMode == _RecipientSortMode.activity) { + if (a.isFavourite != b.isFavourite) { + return a.isFavourite ? -1 : 1; + } + final unreadCompare = _unreadFor(b).compareTo(_unreadFor(a)); + if (unreadCompare != 0) { + return unreadCompare; + } + final lastSeenCompare = b.lastSeenTime.compareTo(a.lastSeenTime); + if (lastSeenCompare != 0) { + return lastSeenCompare; + } + } + + return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); + }); + + return filtered; + } + + bool _isSelected(String type, Contact? contact) { + if (widget.currentDestinationType != type) { + return false; + } + if (contact == null) { + return widget.currentRecipientPublicKey == null; + } + return contact.publicKeyHex == widget.currentRecipientPublicKey; + } + + IconData _typeIcon(Contact contact) { + switch (contact.type) { + case ContactType.channel: + return Icons.broadcast_on_personal_rounded; + case ContactType.room: + return Icons.meeting_room_outlined; + case ContactType.repeater: + return Icons.router_outlined; + case ContactType.sensor: + return Icons.sensors_outlined; + case ContactType.chat: + return Icons.person_rounded; + case ContactType.none: + return Icons.help_outline_rounded; + } + } + + Color _sectionColor(BuildContext context, String type) { + final colorScheme = Theme.of(context).colorScheme; + switch (type) { + case 'channel': + return Color.alphaBlend( + colorScheme.tertiary.withValues(alpha: 0.55), + colorScheme.primary.withValues(alpha: 0.35), + ); + case 'room': + return colorScheme.secondary; + case 'contact': + return colorScheme.primary; + default: + return colorScheme.tertiary; + } + } + + String _sectionDescription(AppLocalizations l10n, String type) { + switch (type) { + case 'channel': + return l10n.channelsSectionDescription; + case 'room': + return l10n.roomsSectionDescription; + case 'contact': + return l10n.contactsSectionDescription; + default: + return ''; + } + } + + MessagesProvider? _resolveMessagesProvider(BuildContext context) { + if (widget.messagesProvider != null) { + return widget.messagesProvider; + } + + try { + return Provider.of(context); + } on ProviderNotFoundException { + return null; + } + } + + String _formatRelativeTime(BuildContext context, DateTime when) { + final l10n = AppLocalizations.of(context)!; + final diff = DateTime.now().difference(when); + + if (diff.inMinutes < 1) return l10n.justNow; + if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); + if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours); + return l10n.daysAgo(diff.inDays); + } + + _ChannelPreviewData _channelPreviewData( + BuildContext context, + Contact channel, + MessagesProvider? messagesProvider, + ) { + if (messagesProvider == null) { + return const _ChannelPreviewData(); + } + + final lastActivityAt = messagesProvider.getLastActivityForDestination( + channel, + ); + final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + final channelMessages = messagesProvider.getMessagesForChannel(channelIdx) + ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + final participantNames = []; + + for (final message in channelMessages) { + final senderName = message.senderName?.trim(); + if (senderName == null || senderName.isEmpty) { + continue; + } + if (!participantNames.contains(senderName)) { + participantNames.add(senderName); + } + } + + return _ChannelPreviewData( + activityLabel: lastActivityAt == null + ? null + : _formatRelativeTime(context, lastActivityAt), + participantNames: participantNames, + ); + } + + Contact? _findParticipantContact(String name) { + final normalizedName = name.trim(); + + for (final contact in widget.contacts) { + if (!contact.isChannel && contact.advName.trim() == normalizedName) { + return contact; + } + } + + for (final contact in widget.contacts) { + if (!contact.isChannel && contact.displayName.trim() == normalizedName) { + return contact; + } + } + + return null; + } + + String _contactActivityLabel( + BuildContext context, + Contact contact, + MessagesProvider? messagesProvider, + ) { + final lastActivityAt = + messagesProvider?.getLastActivityForDestination(contact) ?? + contact.lastSeenTime; + + return _formatRelativeTime(context, lastActivityAt); + } + + IconData _channelLocationSharingIcon(ChannelLocationSharingMode mode) { + switch (mode) { + case ChannelLocationSharingMode.hardware: + case ChannelLocationSharingMode.appFallback: + return Icons.location_pin; + } + } + + Color _channelLocationSharingColor( + BuildContext context, + ChannelLocationSharingMode mode, + ) { + return const Color(0xFF16A34A); + } + + Widget _buildTextSubtitle( + BuildContext context, + Contact contact, + String subtitle, + ) { + final colorScheme = Theme.of(context).colorScheme; + + return Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: contact.isChannel ? null : 'monospace', + ), + ); + } + + Widget _buildChannelSubtitle( + BuildContext context, + Contact channel, + _ChannelPreviewData previewData, + ) { + final colorScheme = Theme.of(context).colorScheme; + + if (previewData.participantNames.isEmpty) { + return Text( + AppLocalizations.of(context)!.noRecentChatters, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ); + } + + return Row( + children: [ + _ParticipantAvatarStack( + key: Key('channel-participants-${channel.publicKeyHex}'), + names: previewData.participantNames, + contactForName: _findParticipantContact, + ), + ], + ); + } + + Widget _buildChannelRecipientCard( + BuildContext context, + Contact channel, + MessagesProvider? messagesProvider, + ) { + final previewData = _channelPreviewData(context, channel, messagesProvider); + ChannelLocationSharingMode? sharingMode; + try { + sharingMode = context + .watch() + .channelLocationSharingModeForChannel( + channel.publicKey.length > 1 ? channel.publicKey[1] : 0, + ); + } on ProviderNotFoundException { + sharingMode = null; + } + + return _buildRecipientCard( + context: context, + type: 'channel', + contact: channel, + title: channel.getLocalizedDisplayName(context), + subtitle: _buildChannelSubtitle(context, channel, previewData), + avatarMarkerMode: sharingMode, + unreadCount: _unreadFor(channel), + isSelected: _isSelected('channel', channel), + compact: true, + activityLabel: previewData.activityLabel, + onTap: () { + widget.onSelect('channel', channel); + Navigator.pop(context); + }, + ); + } + + bool _isDenseSection(String type) => type == 'channel' || type == 'contact'; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + final messagesProvider = _resolveMessagesProvider(context); + final filteredContacts = _filterAndSortContacts(widget.contacts); + final filteredRooms = _filterAndSortContacts(widget.rooms); + final filteredChannels = _filterAndSortContacts( + widget.channels, + prioritizePublicChannel: true, + ); + final hasChannels = widget.channels.isNotEmpty; + final hasContacts = widget.contacts.isNotEmpty; + final hasRooms = widget.rooms.isNotEmpty; + final hasAnyRecipients = hasChannels || hasContacts || hasRooms; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.88, + ), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.12), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), + child: Column( + children: [ + Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.selectRecipient, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + 'Pick a channel, room, or direct contact.', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + const SizedBox(width: 12), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: 0.18, + ), + ), + ), + child: IconButton( + onPressed: () => Navigator.pop(context), + tooltip: l10n.close, + icon: const Icon(Icons.close_rounded), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: l10n.searchRecipients, + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + filled: true, + fillColor: colorScheme.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + ), + const SizedBox(width: 10), + _buildSortMenuButton(context), + ], + ), + ], + ), + ), + Flexible( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + children: [ + if (widget.showAllOption) + _buildAllOptionCard(context, l10n, colorScheme), + if (hasChannels) + _buildSectionCard( + context, + type: 'channel', + title: l10n.channels, + icon: Icons.broadcast_on_personal_rounded, + count: filteredChannels.length, + emptyLabel: l10n.noChannelsFound, + children: [ + for (final channel in filteredChannels) + _buildChannelRecipientCard( + context, + channel, + messagesProvider, + ), + ], + ), + if (hasContacts) + _buildSectionCard( + context, + type: 'contact', + title: l10n.contacts, + icon: Icons.people_alt_rounded, + count: filteredContacts.length, + emptyLabel: l10n.noContactsFound, + children: [ + for (final contact in filteredContacts) + _buildRecipientCard( + context: context, + type: 'contact', + contact: contact, + title: contact.displayName, + activityLabel: _contactActivityLabel( + context, + contact, + messagesProvider, + ), + unreadCount: _unreadFor(contact), + isSelected: _isSelected('contact', contact), + compact: true, + onTap: () { + widget.onSelect('contact', contact); + Navigator.pop(context); + }, + ), + ], + ), + if (hasRooms) + _buildSectionCard( + context, + type: 'room', + title: l10n.rooms, + icon: Icons.meeting_room_outlined, + count: filteredRooms.length, + emptyLabel: l10n.noRoomsFound, + children: [ + for (final room in filteredRooms) + _buildRecipientCard( + context: context, + type: 'room', + contact: room, + title: room.displayName, + subtitle: _buildTextSubtitle( + context, + room, + room.publicKeyShort, + ), + unreadCount: _unreadFor(room), + isSelected: _isSelected('room', room), + onTap: () { + widget.onSelect('room', room); + Navigator.pop(context); + }, + ), + ], + ), + if (!hasAnyRecipients) + Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Column( + children: [ + Icon( + Icons.people_outline_rounded, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + l10n.noRecipientsAvailable, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSortMenuButton(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colorScheme = Theme.of(context).colorScheme; + + return PopupMenuButton<_RecipientSortMode>( + tooltip: AppLocalizations.of(context)!.sort, + initialValue: _sortMode, + onSelected: (sortMode) { + setState(() { + _sortMode = sortMode; + }); + }, + itemBuilder: (context) => [ + PopupMenuItem<_RecipientSortMode>( + value: _RecipientSortMode.activity, + child: Row( + children: [ + Icon( + Icons.flash_on_rounded, + size: 18, + color: _sortMode == _RecipientSortMode.activity + ? colorScheme.primary + : null, + ), + const SizedBox(width: 8), + Text(l10n.active), + ], + ), + ), + PopupMenuItem<_RecipientSortMode>( + value: _RecipientSortMode.favorites, + child: Row( + children: [ + Icon( + Icons.star_rounded, + size: 18, + color: _sortMode == _RecipientSortMode.favorites + ? colorScheme.primary + : null, + ), + const SizedBox(width: 8), + Text(l10n.favourites), + ], + ), + ), + PopupMenuItem<_RecipientSortMode>( + value: _RecipientSortMode.alphabetical, + child: Row( + children: [ + const Icon(Icons.sort_by_alpha_rounded, size: 18), + const SizedBox(width: 8), + Text(AppLocalizations.of(context)!.aToZ), + ], + ), + ), + ], + child: Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + ), + alignment: Alignment.center, + child: Icon( + _sortMode == _RecipientSortMode.activity + ? Icons.flash_on_rounded + : _sortMode == _RecipientSortMode.favorites + ? Icons.star_rounded + : Icons.sort_by_alpha_rounded, + color: colorScheme.primary, + ), + ), + ); + } + + Widget _buildSectionCard( + BuildContext context, { + required String type, + required String title, + required IconData icon, + required int count, + required String emptyLabel, + required List children, + }) { + final colorScheme = Theme.of(context).colorScheme; + final accentColor = _sectionColor(context, type); + final compact = _isDenseSection(type); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: EdgeInsets.all(compact ? 12 : 14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: accentColor.withValues(alpha: 0.14)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: compact ? 34 : 38, + height: compact ? 34 : 38, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Icon(icon, size: compact ? 16 : 18, color: accentColor), + ), + SizedBox(width: compact ? 8 : 10), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + ), + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 9 : 10, + vertical: compact ? 4 : 5, + ), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: accentColor.withValues(alpha: 0.18), + ), + ), + child: Text( + '$count', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: accentColor, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + _sectionDescription(AppLocalizations.of(context)!, type), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + SizedBox(height: compact ? 10 : 12), + if (children.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + emptyLabel, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ) + else ...[ + for (var i = 0; i < children.length; i++) ...[ + children[i], + if (i != children.length - 1) SizedBox(height: compact ? 6 : 8), + ], + ], + ], + ), + ); + } + + Widget _buildAllOptionCard( + BuildContext context, + AppLocalizations l10n, + ColorScheme colorScheme, + ) { + final isSelected = _isSelected('all', null); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.42) + : colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.28) + : colorScheme.outlineVariant.withValues(alpha: 0.2), + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: () { + widget.onSelect('all', null); + Navigator.pop(context); + }, + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Icon( + Icons.all_inbox_rounded, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.showAll, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 4), + Text( + l10n.allMessages, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + _buildTrailing( + context, + unreadCount: widget.unreadCount, + isSelected: isSelected, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildRecipientCard({ + required BuildContext context, + required String type, + required Contact contact, + required String title, + Widget? subtitle, + ChannelLocationSharingMode? avatarMarkerMode, + required int unreadCount, + required bool isSelected, + bool compact = false, + String? activityLabel, + required VoidCallback onTap, + }) { + final colorScheme = Theme.of(context).colorScheme; + final accentColor = _sectionColor(context, type); + final markerColor = avatarMarkerMode == null + ? accentColor + : _channelLocationSharingColor(context, avatarMarkerMode); + final markerIcon = avatarMarkerMode == null + ? _typeIcon(contact) + : _channelLocationSharingIcon(avatarMarkerMode); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(compact ? 18 : 20), + onTap: onTap, + child: Ink( + decoration: BoxDecoration( + color: isSelected + ? accentColor.withValues(alpha: 0.12) + : colorScheme.surface.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(compact ? 18 : 20), + border: Border.all( + color: isSelected + ? accentColor.withValues(alpha: 0.24) + : colorScheme.outlineVariant.withValues(alpha: 0.18), + ), + ), + child: Padding( + padding: EdgeInsets.all(compact ? 10 : 12), + child: Row( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + ContactAvatar( + contact: contact, + radius: compact ? 20 : 22, + displayName: title, + ), + Positioned( + right: -3, + bottom: -3, + child: Container( + width: compact ? 16 : 18, + height: compact ? 16 : 18, + decoration: BoxDecoration( + color: colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: markerColor.withValues(alpha: 0.3), + ), + ), + alignment: Alignment.center, + child: Icon( + markerIcon, + size: compact ? 9 : 10, + color: markerColor, + ), + ), + ), + ], + ), + SizedBox(width: compact ? 10 : 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + ), + ), + ), + if (contact.isPublicChannel) ...[ + SizedBox(width: compact ? 6 : 8), + Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 7 : 8, + vertical: compact ? 2 : 3, + ), + decoration: BoxDecoration( + color: accentColor.withValues( + alpha: 0.10, + ), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + AppLocalizations.of(context)!.public, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: accentColor, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ], + ), + ), + if (activityLabel != null) ...[ + SizedBox(width: compact ? 8 : 10), + Text( + activityLabel, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ], + ], + ), + if (subtitle != null) ...[ + SizedBox(height: compact ? 2 : 4), + subtitle, + ], + ], + ), + ), + SizedBox(width: compact ? 8 : 10), + _buildTrailing( + context, + unreadCount: unreadCount, + isSelected: isSelected, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildTrailing( + BuildContext context, { + required int unreadCount, + required bool isSelected, + }) { + final colorScheme = Theme.of(context).colorScheme; + + if (unreadCount <= 0 && !isSelected) { + return const SizedBox.shrink(); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (unreadCount > 0) + Container( + key: Key('unread-badge-$unreadCount'), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + unreadCount > 99 ? '99+' : '$unreadCount', + style: TextStyle( + color: colorScheme.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + if (unreadCount > 0 && isSelected) const SizedBox(width: 8), + if (isSelected) + Icon(Icons.check_circle_rounded, color: colorScheme.primary), + ], + ); + } +} + +class _ChannelPreviewData { + final String? activityLabel; + final List participantNames; + + const _ChannelPreviewData({ + this.activityLabel, + this.participantNames = const [], + }); +} + +class _ParticipantAvatarStack extends StatelessWidget { + final List names; + final Contact? Function(String name) contactForName; + static const int _visibleCount = 4; + + const _ParticipantAvatarStack({ + super.key, + required this.names, + required this.contactForName, + }); + + @override + Widget build(BuildContext context) { + final visibleNames = names.take(_visibleCount).toList(); + final overflowCount = names.length - visibleNames.length; + const avatarSize = 20.0; + const spacing = 14.0; + final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0); + final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing; + + return SizedBox( + width: width, + height: avatarSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < visibleNames.length; i++) + Positioned( + left: i * spacing, + top: 0, + child: _ParticipantAvatar( + name: visibleNames[i], + contact: contactForName(visibleNames[i]), + ), + ), + if (overflowCount > 0) + Positioned( + left: visibleNames.length * spacing, + top: 0, + child: _ParticipantOverflowAvatar(count: overflowCount), + ), + ], + ), + ); + } +} + +class _ParticipantOverflowAvatar extends StatelessWidget { + final int count; + + const _ParticipantOverflowAvatar({required this.count}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all(color: colorScheme.surface, width: 2), + ), + alignment: Alignment.center, + child: Text( + '+$count', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + fontSize: 8, + ), + ), + ); + } +} + +class _ParticipantAvatar extends StatelessWidget { + final String name; + final Contact? contact; + + const _ParticipantAvatar({required this.name, required this.contact}); + + @override + Widget build(BuildContext context) { + if (contact != null) { + final surfaceColor = Theme.of(context).colorScheme.surface; + + return SizedBox( + width: 20, + height: 20, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: surfaceColor, width: 2), + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: ClipOval(child: ContactAvatar(contact: contact!, radius: 6)), + ), + ), + ); + } + + final colorScheme = Theme.of(context).colorScheme; + + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + shape: BoxShape.circle, + border: Border.all(color: colorScheme.surface, width: 2), + ), + alignment: Alignment.center, + child: Text( + AvatarLabelHelper.buildLabel(name), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onTertiaryContainer, + fontSize: 8, + ), + ), + ); + } +} diff --git a/lib/widgets/messages/sar_update_sheet.dart b/lib/widgets/messages/sar_update_sheet.dart new file mode 100644 index 0000000..be791f7 --- /dev/null +++ b/lib/widgets/messages/sar_update_sheet.dart @@ -0,0 +1,1319 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; +import '../../providers/contacts_provider.dart'; +import '../../models/contact.dart'; +import '../../models/sar_template.dart'; +import '../../services/validation_service.dart'; +import '../../services/sar_template_service.dart'; +import '../../l10n/app_localizations.dart'; + +/// SAR Update Sheet - Modal bottom sheet for creating and sending SAR markers +/// This widget is public so it can be used from both messages_tab.dart and map_tab.dart +class SarUpdateSheet extends StatefulWidget { + final Future Function( + String emoji, + String name, + Position, + Uint8List?, + bool, + bool sendToAllContacts, + int colorIndex, + ) + onSend; + final Position? prePopulatedPosition; + final bool allowLocationUpdate; + + const SarUpdateSheet({ + super.key, + required this.onSend, + this.prePopulatedPosition, + this.allowLocationUpdate = true, + }); + + @override + State createState() => _SarUpdateSheetState(); +} + +class _SarUpdateSheetState extends State { + SarTemplate? _selectedTemplate; + List _templates = []; + final SarTemplateService _templateService = SarTemplateService(); + Position? _currentPosition; + bool _loadingLocation = false; + String? _locationError; + Contact? + _selectedContact; // Can be room or channel (public channel is in contacts) + bool _sendToAllContacts = false; // New option: send to all team contacts + final TextEditingController _notesController = TextEditingController(); + + // Manual coordinates + bool _useManualCoordinates = false; + final TextEditingController _manualLatController = TextEditingController(); + final TextEditingController _manualLonController = TextEditingController(); + String? _latitudeError; + String? _longitudeError; + + @override + void initState() { + super.initState(); + _initializeTemplates(); + // Use pre-populated position if provided, otherwise get current location + if (widget.prePopulatedPosition != null) { + _currentPosition = widget.prePopulatedPosition; + } else { + _getCurrentLocation(); + } + _setDefaultDestination(); + } + + Future _initializeTemplates() async { + if (!_templateService.isInitialized) { + await _templateService.initialize(); + } + if (mounted) { + setState(() { + _templates = _templateService.templates; + // Select first template by default + if (_templates.isNotEmpty) { + _selectedTemplate = _templates.first; + } + }); + } + } + + void _setDefaultDestination() { + // Set default destination with priority for SAR operations: + // 1. "All Team Contacts" - broadcast to entire team (DEFAULT for SAR) + // 2. Individual team contact - if only one contact exists + // 3. Rooms (persistent storage) + // 4. Public channel (fallback) + WidgetsBinding.instance.addPostFrameCallback((_) { + final contactsProvider = context.read(); + + // Get all possible destinations (rooms + channels) + final roomsAndChannels = contactsProvider.roomsAndChannels; + + // Get known team contacts (for direct messaging) + final teamContacts = contactsProvider.contacts + .where((c) => c.isChat) + .toList(); + + // Priority 1: Default to "All Team Contacts" if multiple team members exist + // This is the most appropriate for SAR operations (broadcast critical info) + if (teamContacts.length > 1) { + if (mounted) { + setState(() { + _sendToAllContacts = true; + _selectedContact = null; + }); + } + return; + } + + // Priority 2: If only one team contact, use it directly + if (teamContacts.length == 1) { + if (mounted) { + setState(() { + _sendToAllContacts = false; + _selectedContact = teamContacts.first; + }); + } + return; + } + + // Priority 3: Fall back to rooms (persistent storage) + if (roomsAndChannels.any((c) => c.isRoom)) { + if (mounted) { + setState(() { + _sendToAllContacts = false; + _selectedContact = roomsAndChannels.firstWhere((c) => c.isRoom); + }); + } + return; + } + + // Priority 4: Fall back to public channel + if (roomsAndChannels.isNotEmpty) { + if (mounted) { + setState(() { + _sendToAllContacts = false; + _selectedContact = roomsAndChannels.first; + }); + } + } + }); + } + + @override + void dispose() { + _notesController.dispose(); + _manualLatController.dispose(); + _manualLonController.dispose(); + super.dispose(); + } + + /// Validate manual latitude input + void _validateLatitude(String value) { + final validator = ValidationService(); + if (value.isEmpty) { + setState(() { + _latitudeError = null; + }); + return; + } + + final lat = double.tryParse(value.trim()); + final result = validator.validateLatitude(lat); + setState(() { + _latitudeError = result.isValid ? null : result.errorMessage; + }); + } + + /// Validate manual longitude input + void _validateLongitude(String value) { + final validator = ValidationService(); + if (value.isEmpty) { + setState(() { + _longitudeError = null; + }); + return; + } + + final lon = double.tryParse(value.trim()); + final result = validator.validateLongitude(lon); + setState(() { + _longitudeError = result.isValid ? null : result.errorMessage; + }); + } + + /// Get position from manual coordinates or GPS + Position? _getPosition() { + if (_useManualCoordinates) { + // Parse manual coordinates + final lat = double.tryParse(_manualLatController.text.trim()); + final lon = double.tryParse(_manualLonController.text.trim()); + + if (lat == null || lon == null) return null; + + // Validate coordinates + final validator = ValidationService(); + final latResult = validator.validateLatitude(lat); + final lonResult = validator.validateLongitude(lon); + + if (!latResult.isValid || !lonResult.isValid) return null; + + // Create a Position object with manual coordinates + return Position( + latitude: lat, + longitude: lon, + timestamp: DateTime.now(), + accuracy: 0.0, // Manual coordinates have perfect accuracy + altitude: 0.0, + altitudeAccuracy: 0.0, + heading: 0.0, + headingAccuracy: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + ); + } else { + return _currentPosition; + } + } + + Future _getCurrentLocation() async { + setState(() { + _loadingLocation = true; + _locationError = null; + }); + + try { + // Check if location services are enabled + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!mounted) return; + if (!serviceEnabled) { + setState(() { + _locationError = AppLocalizations.of( + context, + )!.locationServicesDisabled; + _loadingLocation = false; + }); + return; + } + + // Check permissions + LocationPermission permission = await Geolocator.checkPermission(); + if (!mounted) return; + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (!mounted) return; + if (permission == LocationPermission.denied) { + setState(() { + _locationError = AppLocalizations.of( + context, + )!.locationPermissionDenied; + _loadingLocation = false; + }); + return; + } + } + + if (permission == LocationPermission.deniedForever) { + setState(() { + _locationError = AppLocalizations.of( + context, + )!.locationPermissionPermanentlyDenied; + _loadingLocation = false; + }); + return; + } + + // Get current position + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 0, + ), + ); + + if (mounted) { + setState(() { + _currentPosition = position; + _loadingLocation = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _locationError = 'Failed to get location: $e'; + _loadingLocation = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + // Get keyboard height to adjust padding + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + final bottomSafeArea = MediaQuery.of(context).padding.bottom; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return AnimatedPadding( + padding: EdgeInsets.only(bottom: keyboardHeight), + duration: const Duration(milliseconds: 100), + child: Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + children: [ + Text( + AppLocalizations.of(context)!.sendSarMarker, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + AppLocalizations.of(context)!.quickLocationMarker, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(width: 48), // Spacer to keep title centered + ], + ), + ), + // Content + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + // Add bottom padding for button area (button + padding + safe area) + // Button height ~48px + container padding 32px + safe area + bottom: 80 + bottomSafeArea, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Marker type selection + Text( + AppLocalizations.of(context)!.markerType, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ..._templates.map((template) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TemplateChip( + template: template, + isSelected: _selectedTemplate?.id == template.id, + onTap: () => + setState(() => _selectedTemplate = template), + ), + ); + }), + SizedBox(height: 16), + + // Destination selection (compact dropdown with rooms, channels, and all contacts) + Text( + AppLocalizations.of(context)!.sendTo, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Consumer( + builder: (context, contactsProvider, child) { + // Get individual team contacts (chat type) + final teamContacts = contactsProvider.chatContacts; + + // Get rooms and channels + final roomsAndChannels = + contactsProvider.roomsAndChannels; + + // Build destinations list with priority: + // 1. Team contacts first (most reliable for SAR) + // 2. Rooms second (persistent storage) + // 3. Channels last (ephemeral, fallback) + final destinations = [ + ...teamContacts, + ...roomsAndChannels.where((c) => c.isRoom), + ...roomsAndChannels.where((c) => c.isChannel), + ]; + + final chatContactsCount = teamContacts.length; + + if (destinations.isEmpty && chatContactsCount == 0) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of( + context, + )!.noDestinationsAvailable, + style: TextStyle( + color: Colors.red.shade900, + fontSize: 11, + ), + ), + ), + ], + ), + ); + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _sendToAllContacts + ? 'all_contacts' + : _selectedContact?.publicKeyHex, + hint: Row( + children: [ + Icon( + Icons.arrow_drop_down_circle, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Text( + AppLocalizations.of( + context, + )!.selectDestination, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + dropdownColor: + colorScheme.surfaceContainerHighest, + isExpanded: true, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 14, + ), + icon: Icon( + Icons.arrow_drop_down, + color: colorScheme.onSurface, + ), + items: [ + // Add "All Team Contacts" option if there are any chat contacts + if (chatContactsCount > 0) + DropdownMenuItem( + value: 'all_contacts', + child: Row( + children: [ + Icon( + Icons.group, + size: 18, + color: colorScheme.onSurface, + ), + SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of( + context, + )!.allTeamContacts, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + // Add all destinations (team contacts, rooms, channels) + ...destinations.map((contact) { + // Choose icon based on contact type + IconData iconData; + if (contact.isChat) { + iconData = Icons.person; // Team member + } else if (contact.isRoom) { + iconData = + Icons.storage; // Room (persistent) + } else { + iconData = + Icons.public; // Channel (ephemeral) + } + + return DropdownMenuItem( + value: contact.publicKeyHex, + child: Row( + children: [ + Icon( + iconData, + size: 18, + color: colorScheme.onSurface, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + contact.getLocalizedDisplayName( + context, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }), + ], + onChanged: (value) { + setState(() { + if (value == 'all_contacts') { + _sendToAllContacts = true; + _selectedContact = null; + } else { + _sendToAllContacts = false; + _selectedContact = destinations.firstWhere( + (c) => c.publicKeyHex == value, + ); + } + }); + }, + ), + ), + ); + }, + ), + const SizedBox(height: 12), + + // Compact info banner + Consumer( + builder: (context, contactsProvider, child) { + if (_sendToAllContacts) { + final chatContactsCount = + contactsProvider.chatContacts.length; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.green.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.check_circle_outline, + color: Colors.green, + size: 20, + ), + SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of( + context, + )!.directMessagesInfo(chatContactsCount), + style: TextStyle( + color: Colors.green.shade900, + fontSize: 11, + ), + ), + ), + ], + ), + ); + } else if (_selectedContact != null) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _selectedContact!.isChannel + ? Colors.orange.withValues(alpha: 0.1) + : Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _selectedContact!.isChannel + ? Colors.orange.withValues(alpha: 0.3) + : Colors.blue.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + _selectedContact!.isChannel + ? Icons.warning_amber + : Icons.check_circle_outline, + color: _selectedContact!.isChannel + ? Colors.orange + : Colors.blue, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _selectedContact!.isChannel + ? AppLocalizations.of( + context, + )!.ephemeralBroadcastInfo + : AppLocalizations.of( + context, + )!.persistentRoomInfo, + style: TextStyle( + color: _selectedContact!.isChannel + ? Colors.orange.shade900 + : Colors.blue.shade900, + fontSize: 11, + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + const SizedBox(height: 24), + + // Location display + Row( + children: [ + Text( + AppLocalizations.of(context)!.location, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + if (!widget.allowLocationUpdate) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.blue.withValues(alpha: 0.5), + width: 1, + ), + ), + child: Text( + AppLocalizations.of(context)!.fromMap, + style: const TextStyle( + color: Colors.blue, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 12), + if (_loadingLocation) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + colorScheme.primary, + ), + ), + ), + SizedBox(width: 16), + Text( + AppLocalizations.of(context)!.gettingLocation, + style: TextStyle(color: colorScheme.onSurface), + ), + ], + ), + ) + else if (_locationError != null) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.locationError, + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + const SizedBox(height: 4), + Text( + _locationError!, + style: TextStyle( + color: Colors.red.shade700, + fontSize: 12, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon( + Icons.refresh, + color: Colors.red, + ), + onPressed: _getCurrentLocation, + tooltip: AppLocalizations.of(context)!.retry, + ), + ], + ), + ) + else if (_currentPosition != null) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.location_on, + size: 20, + color: Colors.green, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '${_currentPosition!.latitude.toStringAsFixed(5)}, ${_currentPosition!.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + ), + ), + // Only show refresh button if location updates are allowed + if (widget.allowLocationUpdate) + IconButton( + icon: Icon( + Icons.refresh, + size: 20, + color: colorScheme.onSurface, + ), + onPressed: _getCurrentLocation, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: AppLocalizations.of( + context, + )!.refreshLocation, + ), + ], + ), + ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.my_location, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + AppLocalizations.of( + context, + )!.accuracyMeters( + _currentPosition!.accuracy.round(), + ), + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 16), + + // Manual coordinates toggle + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.edit_location_alt, + size: 20, + color: colorScheme.onSurface, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of( + context, + )!.manualCoordinates, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + Text( + AppLocalizations.of( + context, + )!.enterCoordinatesManually, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Switch( + value: _useManualCoordinates, + onChanged: (value) { + setState(() { + _useManualCoordinates = value; + // Clear errors when toggling + _latitudeError = null; + _longitudeError = null; + }); + }, + ), + ], + ), + ), + + // Manual coordinate input fields + if (_useManualCoordinates) ...[ + const SizedBox(height: 16), + // Latitude input + TextField( + controller: _manualLatController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurface, + ), + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.latitudeLabel, + hintText: '46.0569', + errorText: _latitudeError, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.all(16), + ), + onChanged: _validateLatitude, + ), + const SizedBox(height: 12), + // Longitude input + TextField( + controller: _manualLonController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurface, + ), + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.longitudeLabel, + hintText: '14.5058', + errorText: _longitudeError, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.all(16), + ), + onChanged: _validateLongitude, + ), + const SizedBox(height: 8), + // Example hint + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Colors.blue, + ), + SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of( + context, + )!.exampleCoordinates, + style: const TextStyle( + fontSize: 12, + color: Colors.blue, + ), + ), + ), + ], + ), + ), + ], + + SizedBox(height: 24), + + // Optional notes + Text( + AppLocalizations.of(context)!.notesOptional, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + maxLength: 100, + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: AppLocalizations.of( + context, + )!.addAdditionalInformation, + hintStyle: TextStyle( + fontSize: 14, + color: colorScheme.onSurfaceVariant, + ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.all(16), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + // Bottom action button + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + top: false, + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: + (!_sendToAllContacts && _selectedContact == null) || + _selectedTemplate == null + ? null + : () async { + final validator = ValidationService(); + final notes = _notesController.text.trim(); + + // Validate notes length if provided + if (notes.isNotEmpty) { + final notesResult = validator.validateName( + notes, + maxLength: 100, + ); + if (!notesResult.isValid) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(notesResult.errorMessage!), + backgroundColor: Colors.red, + ), + ); + } + return; + } + } + + // Get position (from manual input or GPS) + final position = _getPosition(); + if (position == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _useManualCoordinates + ? AppLocalizations.of( + context, + )!.pleaseEnterValidCoordinates + : AppLocalizations.of( + context, + )!.locationNotAvailable, + ), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + // Validate coordinates + final coordResult = validator.validateCoordinates( + position.latitude, + position.longitude, + ); + if (!coordResult.isValid) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(coordResult.errorMessage!), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + // Validate location accuracy (warn if >50m) - only for GPS + if (!_useManualCoordinates && + position.accuracy > 50.0) { + final shouldContinue = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + AppLocalizations.of( + context, + )!.lowLocationAccuracy, + ), + content: Text( + AppLocalizations.of( + context, + )!.lowAccuracyWarning( + position.accuracy.round(), + ), + ), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(context, false), + child: Text( + AppLocalizations.of(context)!.cancel, + ), + ), + TextButton( + onPressed: () => + Navigator.pop(context, true), + child: Text( + AppLocalizations.of(context)!.continue_, + ), + ), + ], + ), + ); + if (shouldContinue != true) return; + } + + // Combine template name with optional notes + String displayText; + if (notes.isNotEmpty) { + // Include both template name and custom notes + displayText = + '${_selectedTemplate!.name} - $notes'; + } else { + // Just the template name + displayText = _selectedTemplate!.name; + } + + // Send SAR marker with emoji, display text, and color index + await widget.onSend( + _selectedTemplate!.emoji, + displayText, + position, + _sendToAllContacts + ? null + : (_selectedContact!.isChannel + ? null + : _selectedContact!.publicKey), + _sendToAllContacts + ? false + : _selectedContact!.isChannel, + _sendToAllContacts, + _selectedTemplate! + .getColorIndex(), // Include color index + ); + if (context.mounted) { + Navigator.pop(context); + } + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + disabledBackgroundColor: Colors.grey, + disabledForegroundColor: Colors.white70, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: Icon(Icons.send, size: 20), + label: Text( + AppLocalizations.of(context)!.sendSarMarker, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Template Chip widget - Displays a selectable SAR template +class TemplateChip extends StatelessWidget { + final SarTemplate template; + final bool isSelected; + final VoidCallback onTap; + + const TemplateChip({ + super.key, + required this.template, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final color = template.color; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: isSelected + ? Border.all(color: color, width: 2) + : Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Text(template.emoji, style: const TextStyle(fontSize: 32)), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + template.getLocalizedName(context), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isSelected ? color : colorScheme.onSurface, + ), + ), + if (template.description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + template.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ], + ), + ), + if (isSelected) Icon(Icons.check_circle, color: color, size: 24), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/messages/system_message_bubble.dart b/lib/widgets/messages/system_message_bubble.dart new file mode 100644 index 0000000..34ca845 --- /dev/null +++ b/lib/widgets/messages/system_message_bubble.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import '../../models/message.dart'; +import '../../utils/message_extensions.dart'; + +class SystemMessageBubble extends StatelessWidget { + final Message message; + + const SystemMessageBubble({super.key, required this.message}); + + Color _getLevelColor(String? level) { + switch (level?.toLowerCase()) { + case 'success': + return Colors.green; + case 'warning': + return Colors.orange; + case 'error': + return Colors.red; + case 'info': + default: + return Colors.blue.shade300; + } + } + + IconData _getLevelIcon(String? level) { + switch (level?.toLowerCase()) { + case 'success': + return Icons.check_circle_outline; + case 'warning': + return Icons.warning_amber_outlined; + case 'error': + return Icons.error_outline; + case 'info': + default: + return Icons.info_outline; + } + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final level = message.senderName ?? 'info'; + final levelColor = _getLevelColor(level); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: levelColor.withValues(alpha: isDarkMode ? 0.18 : 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: levelColor.withValues(alpha: isDarkMode ? 0.3 : 0.16), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_getLevelIcon(level), size: 16, color: levelColor), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + level.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: levelColor, + fontWeight: FontWeight.bold, + letterSpacing: 0.4, + ), + ), + ), + Text( + message.getLocalizedTimeAgo(context), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + message.text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + height: 1.3, + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/messages/tictactoe_message_bubble.dart b/lib/widgets/messages/tictactoe_message_bubble.dart new file mode 100644 index 0000000..5357f9e --- /dev/null +++ b/lib/widgets/messages/tictactoe_message_bubble.dart @@ -0,0 +1,307 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; +import '../../providers/messages_provider.dart'; +import '../../utils/tictactoe_message_parser.dart'; +import '../../utils/toast_logger.dart'; +import '../../l10n/app_localizations.dart'; + +class TicTacToeMessageBubble extends StatelessWidget { + final Message message; + final bool isSentByMe; + + const TicTacToeMessageBubble({ + super.key, + required this.message, + required this.isSentByMe, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final event = TicTacToeMessageParser.tryParse(message.text); + if (event == null) { + return const SizedBox.shrink(); + } + + final conn = context.watch(); + final contacts = context.read(); + final messages = context.watch().messages; + final selfKey = conn.deviceInfo.publicKey; + if (selfKey == null || selfKey.length < 6) { + return Text(AppLocalizations.of(context)!.tictactoeUnavailable); + } + + final selfKey6 = _key6Hex(selfKey); + final opponent = _resolveOpponentContact( + message: message, + contactsProvider: contacts, + isSentByMe: isSentByMe, + ); + if (opponent == null) { + return Text(AppLocalizations.of(context)!.tictactoeOpponentUnknown); + } + final opponentKey6 = _key6Hex(opponent.publicKey); + + final gameEvents = []; + TicTacToeEvent? start; + for (final m in messages) { + if (!m.isContactMessage) continue; + final parsed = TicTacToeMessageParser.tryParse(m.text); + if (parsed == null || parsed.gameId != event.gameId) continue; + if (!_isSameDmThread( + message: m, + selfKey: selfKey, + opponentKey6: opponentKey6, + )) { + continue; + } + if (parsed.type == TicTacToeEventType.start) { + start ??= parsed; + } else { + gameEvents.add(parsed); + } + } + + start ??= event.type == TicTacToeEventType.start ? event : null; + if (start == null) { + return Text(AppLocalizations.of(context)!.tictactoeWaitingForStart); + } + + final xPlayer = start.playerKey6; + final oPlayer = xPlayer == selfKey6 ? opponentKey6 : selfKey6; + final state = buildTicTacToeState( + gameId: event.gameId, + xPlayerKey6: xPlayer, + oPlayerKey6: oPlayer, + events: gameEvents, + ); + + final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O'; + final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol; + final titleColor = isSentByMe + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface; + final statusColor = isSentByMe + ? colorScheme.onPrimaryContainer.withValues(alpha: 0.85) + : colorScheme.onSurface.withValues(alpha: 0.85); + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 230), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tic-Tac-Toe · Game ${state.gameId}', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + color: titleColor, + ), + ), + const SizedBox(height: 8), + _BoardGrid( + board: state.board, + enabled: isMyTurn, + isSentByMe: isSentByMe, + onTapCell: (idx) => _onCellTap( + context: context, + idx: idx, + state: state, + selfKey6: selfKey6, + opponent: opponent, + connectionProvider: conn, + ), + ), + const SizedBox(height: 8), + Text( + _statusText( + l10n: AppLocalizations.of(context)!, + state: state, + mySymbol: mySymbol, + ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: statusColor), + ), + ], + ), + ); + } + + Future _onCellTap({ + required BuildContext context, + required int idx, + required TicTacToeGameState state, + required String selfKey6, + required Contact opponent, + required ConnectionProvider connectionProvider, + }) async { + if (idx < 0 || idx > 8 || state.board[idx] != null) return; + if (!connectionProvider.deviceInfo.isConnected) { + ToastLogger.error( + context, + AppLocalizations.of(context)!.notConnectedToDevice, + ); + return; + } + + final messagesProvider = context.read(); + final text = TicTacToeMessageParser.encodeMove( + gameId: state.gameId, + cell: idx, + playerKey6: selfKey6, + timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + final messageId = '${DateTime.now().millisecondsSinceEpoch}_ttt_move'; + final senderPublicKeyPrefix = connectionProvider.deviceInfo.publicKey! + .sublist(0, 6); + + final sentMessage = Message( + id: messageId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + text: text, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: opponent.publicKey, + ); + messagesProvider.addSentMessage(sentMessage, contact: opponent); + + final sent = await connectionProvider.sendTextMessage( + contactPublicKey: opponent.publicKey, + text: text, + messageId: messageId, + contact: opponent, + ); + if (!sent) { + messagesProvider.markMessageFailed(messageId); + if (!context.mounted) return; + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToSendTicTacToeMove, + ); + } + } + + static String _statusText({ + required AppLocalizations l10n, + required TicTacToeGameState state, + required String mySymbol, + }) { + if (state.winnerSymbol != null) { + return state.winnerSymbol == mySymbol ? l10n.youWon : l10n.opponentWon; + } + if (state.isDraw) return l10n.gameDraw; + return state.nextSymbol == mySymbol ? l10n.yourTurn : l10n.opponentTurn; + } + + static String _key6Hex(Uint8List key) => key + .sublist(0, math.min(6, key.length)) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toLowerCase(); + + static Contact? _resolveOpponentContact({ + required Message message, + required ContactsProvider contactsProvider, + required bool isSentByMe, + }) { + if (isSentByMe && message.recipientPublicKey != null) { + return contactsProvider.findContactByKey(message.recipientPublicKey!); + } + final sender = message.senderPublicKeyPrefix; + if (sender == null || sender.length < 6) return null; + return contactsProvider.findContactByPrefix( + Uint8List.fromList(sender.sublist(0, 6)), + ); + } + + static bool _isSameDmThread({ + required Message message, + required Uint8List selfKey, + required String opponentKey6, + }) { + final isOwn = message.isSentMessage || message.isFromSelf(selfKey); + if (isOwn) { + final recipient = message.recipientPublicKey; + if (recipient == null || recipient.length < 6) return false; + return _key6Hex(recipient) == opponentKey6; + } + final sender = message.senderPublicKeyPrefix; + if (sender == null || sender.length < 6) return false; + return _key6Hex(sender) == opponentKey6; + } +} + +class _BoardGrid extends StatelessWidget { + final List board; + final bool enabled; + final bool isSentByMe; + final ValueChanged onTapCell; + + const _BoardGrid({ + required this.board, + required this.enabled, + required this.isSentByMe, + required this.onTapCell, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final cellBackground = isSentByMe + ? colorScheme.primaryContainer.withValues(alpha: 0.35) + : colorScheme.surface; + final cellBorder = isSentByMe + ? colorScheme.primary.withValues(alpha: 0.45) + : colorScheme.outline.withValues(alpha: 0.35); + + return SizedBox( + width: 180, + height: 180, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + itemCount: 9, + itemBuilder: (context, idx) { + final value = board[idx]; + return InkWell( + onTap: enabled && value == null ? () => onTapCell(idx) : null, + borderRadius: BorderRadius.circular(8), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: cellBackground, + border: Border.all(color: cellBorder), + ), + child: Text( + value ?? '', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w900, + color: value == 'X' + ? colorScheme.primary + : value == 'O' + ? colorScheme.tertiary + : null, + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/messages/transfer_timeout.dart b/lib/widgets/messages/transfer_timeout.dart new file mode 100644 index 0000000..a1b82bd --- /dev/null +++ b/lib/widgets/messages/transfer_timeout.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +/// Calculates a transfer timeout as 2× the estimated LoRa airtime, +/// with a minimum of 30 seconds. +/// +/// Used by [ImageMessageBubble] and [VoiceMessageBubble] to reset the +/// "loading" spinner when a transfer stalls, allowing the user to retry. +class TransferTimeout { + static const Duration _minimum = Duration(seconds: 30); + + /// Start a one-shot timer based on [txEstimate] × 2 (min 30s). + /// + /// [onTimeout] is called on the UI thread when the timer fires. + /// Returns the [Timer] so the caller can cancel it (e.g. on dispose or + /// when the transfer completes). + static Timer start({ + required Duration txEstimate, + required void Function() onTimeout, + }) { + final timeout = txEstimate * 2; + final effective = timeout < _minimum ? _minimum : timeout; + return Timer(effective, onTimeout); + } +} diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart new file mode 100644 index 0000000..ed24ba6 --- /dev/null +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -0,0 +1,773 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; +import '../../models/message.dart'; +import '../../providers/app_provider.dart'; +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; +import '../../providers/messages_provider.dart'; +import '../../providers/voice_provider.dart'; +import '../../utils/transmission_target_resolver.dart'; +import '../../utils/voice_message_parser.dart'; +import 'transfer_timeout.dart'; + +/// A message bubble that shows a voice recording with play/stop controls. +class VoiceMessageBubble extends StatefulWidget { + final Message message; + final bool isSentByMe; + + const VoiceMessageBubble({ + super.key, + required this.message, + required this.isSentByMe, + }); + + @override + State createState() => _VoiceMessageBubbleState(); +} + +class _VoiceMessageBubbleState extends State { + static const int _maxFetchHops = 3; + static const Duration _recentInboundActivityWindow = Duration(seconds: 3); + bool _isRequesting = false; + bool _isPartialRequest = false; + bool _autoPlayWhenReady = false; + String? _errorText; + Timer? _requestTimeoutTimer; + + @override + void dispose() { + _requestTimeoutTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final radioBw = context.select( + (p) => p.deviceInfo.radioBw, + ); + final radioSf = context.select( + (p) => p.deviceInfo.radioSf, + ); + final radioCr = context.select( + (p) => p.deviceInfo.radioCr, + ); + final voiceId = widget.message.voiceId; + if (voiceId == null) return const SizedBox.shrink(); + + return Consumer( + builder: (context, voiceProvider, _) { + final transferCount = context.select( + (provider) => + provider.transferCountForSession(voiceSessionId: voiceId), + ); + final contactsProvider = context.read(); + final session = voiceProvider.session(voiceId); + final envelope = VoiceEnvelope.tryParseText(widget.message.text); + final sender = TransmissionTargetResolver.resolveLocalTarget( + contactsProvider: contactsProvider, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderName: widget.message.senderName, + ); + final effectivePathLen = sender != null && sender.routeHasPath + ? sender.routeHopCount + : widget.message.pathLen; + final isPlaying = voiceProvider.isPlaying(voiceId); + final isComplete = voiceProvider.isComplete(voiceId); + + if (_isRequesting && isComplete) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _isRequesting = false; + _isPartialRequest = false; + _errorText = null; + }); + }); + } + + if (_autoPlayWhenReady && isComplete && !isPlaying) { + _autoPlayWhenReady = false; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await voiceProvider.play(voiceId); + }); + } + + final received = session?.receivedCount ?? 0; + final total = session?.total ?? envelope?.total ?? 0; + final playbackProgress = voiceProvider.playbackProgress(voiceId); + final packetPresence = + session?.packets.map((packet) => packet != null).toList() ?? + List.filled(total, false); + final isReceivingData = + !_isRequesting && + !isComplete && + _hasRecentInboundActivity( + lastReceivedAt: session?.lastPacketAt, + received: received, + total: total, + ); + final durationSec = + session?.estimatedDurationSeconds ?? + ((envelope?.durationMs ?? 0) / 1000.0); + final durationLabel = _formatDuration(durationSec); + final modeLabel = session?.mode.label ?? envelope?.mode.label ?? '?'; + final waveformBars = _resolveWaveformBars( + session: session, + messageText: widget.message.text, + ); + final txEstimate = _resolveVoiceTransmitEstimate( + session: session, + envelope: envelope, + messageText: widget.message.text, + pathLen: effectivePathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + final txEstimateLabel = _formatTransmitEstimate(txEstimate); + final eta = voiceProvider.estimateRemainingTransferTime(voiceId); + + Future handlePrimaryTap() async { + if (isPlaying) { + await voiceProvider.stop(); + return; + } + if (_isRequesting) { + _cancelReceive(voiceId); + return; + } + if (isComplete) { + await voiceProvider.play(voiceId); + return; + } + if (isReceivingData) { + return; + } + await _requestAndPlayVoice( + voiceId, + envelope: envelope, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + pathLen: effectivePathLen, + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: handlePrimaryTap, + borderRadius: BorderRadius.circular(24), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: widget.isSentByMe + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.secondaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + isPlaying + ? Icons.stop + : (_isRequesting + ? Icons.close + : (isReceivingData + ? Icons.downloading_rounded + : Icons.play_arrow)), + size: 28, + color: widget.isSentByMe + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSecondaryContainer + .withValues(alpha: isReceivingData ? 0.6 : 1.0), + ), + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (isPlaying) + SizedBox( + width: 100, + child: LinearProgressIndicator( + value: playbackProgress, + backgroundColor: Colors.grey.withValues(alpha: 0.3), + ), + ) + else if ((_isRequesting || isReceivingData) && total > 0) + _PacketBlockProgress( + presence: packetPresence, + activeColor: widget.isSentByMe + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.secondary, + highlightMissing: _isPartialRequest, + ) + else + _WaveformBar(isComplete: isComplete, bars: waveformBars), + const SizedBox(height: 4), + Text( + _buildStatusText( + durationLabel: durationLabel, + modeLabel: modeLabel, + txEstimateLabel: txEstimateLabel, + received: received, + total: total, + isComplete: isComplete, + isRequesting: _isRequesting, + isReceivingData: isReceivingData, + isPartialRequest: _isPartialRequest, + errorText: _errorText, + requestingLabel: AppLocalizations.of( + context, + )!.requestingVoice, + fetchingMissingLabel: AppLocalizations.of( + context, + )!.fetchingMissingVoiceFragments, + receivingVoiceLabel: AppLocalizations.of( + context, + )!.receivingVoice, + eta: eta, + isSentByMe: widget.isSentByMe, + transferCount: transferCount, + ), + style: TextStyle( + fontSize: 11, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ], + ); + }, + ); + } + + Future _requestAndPlayVoice( + String sessionId, { + VoiceEnvelope? envelope, + int? radioBw, + int? radioSf, + int? radioCr, + int pathLen = 0, + }) async { + if (_isRequesting) return; + setState(() { + _isRequesting = true; + _isPartialRequest = false; + _autoPlayWhenReady = true; + _errorText = null; + }); + + final connectionProvider = context.read(); + final voiceProvider = context.read(); + voiceProvider.resumeIncomingSession(sessionId); + final contactsProvider = context.read(); + final appProvider = context.read(); + var resolution = await TransmissionTargetResolver.resolveFetchTarget( + contactsProvider: contactsProvider, + refreshContacts: connectionProvider.getContacts, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderName: widget.message.senderName, + maxFetchHops: _maxFetchHops, + ); + if (!mounted) return; + + if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchVoice, + AppLocalizations.of(context)!.senderContactUnknown, + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchVoice, + AppLocalizations.of(context)!.senderRouteUnknown, + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchVoice, + AppLocalizations.of( + context, + )!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'), + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.unreachable) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchVoice, + AppLocalizations.of(context)!.senderRouteNoPathResponse, + ); + return; + } + + var sender = resolution.target!; + var routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + await connectionProvider.getContacts(); + if (!mounted) return; + resolution = await TransmissionTargetResolver.resolveFetchTarget( + contactsProvider: contactsProvider, + refreshContacts: connectionProvider.getContacts, + isSentByMe: widget.isSentByMe, + recipientPublicKey: widget.message.recipientPublicKey, + senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix, + senderName: widget.message.senderName, + maxFetchHops: _maxFetchHops, + ); + if (!mounted) return; + if (resolution.failure == TransmissionTargetFailure.unknownContact) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchVoice, + AppLocalizations.of(context)!.senderContactUnknown, + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.unknownRoute) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchVoice, + AppLocalizations.of(context)!.senderRouteUnknown, + ); + return; + } + if (resolution.failure == TransmissionTargetFailure.tooFar) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchVoice, + AppLocalizations.of( + context, + )!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'), + ); + return; + } + sender = resolution.target!; + routeVerified = await appProvider.verifyRawTransportRoute(sender); + if (!mounted) return; + if (!routeVerified) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchVoice, + AppLocalizations.of(context)!.senderRouteNoRawResponse, + ); + return; + } + } + + if (sender.routeHopCount >= 2) { + _showToast( + AppLocalizations.of( + context, + )!.voiceFetchOverHops('${sender.routeHopCount}'), + ); + } + + final deviceKey = connectionProvider.deviceInfo.publicKey; + if (deviceKey == null || deviceKey.length < 6) { + _clearRequestState(); + await _showBlockingAlert( + AppLocalizations.of(context)!.cannotFetchVoice, + AppLocalizations.of(context)!.deviceKeyUnavailable, + ); + return; + } + + final requesterKey6 = deviceKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + final missing = voiceProvider.missingPacketIndices(sessionId); + final totalPackets = sessionPacketCount( + voiceProvider: voiceProvider, + sessionId: sessionId, + envelope: envelope, + ); + final isPartialResume = + missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets; + if (_isPartialRequest != isPartialResume && mounted) { + setState(() { + _isPartialRequest = isPartialResume; + }); + } + final request = isPartialResume + ? VoiceFetchRequest( + sessionId: sessionId, + want: 'missing', + missingIndices: missing, + requesterKey6: requesterKey6, + ) + : VoiceFetchRequest(sessionId: sessionId, requesterKey6: requesterKey6); + + try { + debugPrint( + '🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}', + ); + await connectionProvider.sendRawVoicePacket( + contactPath: sender.outPath, + contactPathLen: sender.routeEncodedPathLen, + payload: request.encodeBinary(), + ); + } catch (_) { + _setUnavailable(); + return; + } + + // Timeout = 2× estimated LoRa airtime (min 30s). + final effectivePathLen = sender.routeHasPath + ? sender.routeHopCount + : pathLen; + final estimatedDurationMs = + envelope != null && + totalPackets > 0 && + missing.isNotEmpty && + missing.length < totalPackets + ? ((envelope.durationMs * missing.length) / totalPackets).round() + : envelope?.durationMs; + final txEstimate = envelope != null + ? estimateVoiceTransmitDuration( + packetCount: isPartialResume ? missing.length : envelope.total, + mode: envelope.mode, + durationMs: estimatedDurationMs ?? envelope.durationMs, + pathLen: effectivePathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ) + : const Duration(seconds: 15); + _requestTimeoutTimer?.cancel(); + _requestTimeoutTimer = TransferTimeout.start( + txEstimate: txEstimate, + onTimeout: () { + if (mounted && _isRequesting) { + _setUnavailable(); + } + }, + ); + } + + int sessionPacketCount({ + required VoiceProvider voiceProvider, + required String sessionId, + required VoiceEnvelope? envelope, + }) { + return voiceProvider.session(sessionId)?.total ?? envelope?.total ?? 0; + } + + void _setUnavailable() { + if (!mounted) return; + _showToast(AppLocalizations.of(context)!.voiceUnavailable); + setState(() { + _isRequesting = false; + _isPartialRequest = false; + _autoPlayWhenReady = false; + _errorText = AppLocalizations.of(context)!.voiceUnavailable; + }); + } + + void _clearRequestState() { + if (!mounted) return; + setState(() { + _isRequesting = false; + _isPartialRequest = false; + _autoPlayWhenReady = false; + }); + } + + void _cancelReceive(String sessionId) { + if (!mounted) return; + _requestTimeoutTimer?.cancel(); + context.read().cancelIncomingSession(sessionId); + _showToast(AppLocalizations.of(context)!.voiceReceiveCanceled); + setState(() { + _isRequesting = false; + _isPartialRequest = false; + _autoPlayWhenReady = false; + _errorText = AppLocalizations.of(context)!.voiceReceiveCanceled; + }); + } + + void _showToast(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 3)), + ); + } + + Future _showBlockingAlert(String title, String message) async { + if (!mounted) return; + _showToast('$title: $message'); + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + + static String _formatDuration(double seconds) { + final s = seconds.round(); + if (s < 60) return '${s}s'; + return '${s ~/ 60}m ${s % 60}s'; + } + + static String _buildStatusText({ + required String durationLabel, + required String modeLabel, + required String txEstimateLabel, + required int received, + required int total, + required bool isComplete, + required bool isRequesting, + required bool isReceivingData, + required bool isPartialRequest, + required String? errorText, + required String requestingLabel, + required String fetchingMissingLabel, + required String receivingVoiceLabel, + required Duration? eta, + required bool isSentByMe, + required int transferCount, + }) { + if (errorText != null) return errorText; + final progress = total > 0 ? ' ($received/$total)' : ''; + if (isRequesting) { + final actionLabel = isPartialRequest + ? fetchingMissingLabel + : requestingLabel; + return '$actionLabel$progress · ${_formatEta(eta)} · $txEstimateLabel'; + } + if (isReceivingData) { + return '$receivingVoiceLabel$progress · ${_formatEta(eta)} · $txEstimateLabel'; + } + if (!isComplete && total > 0) { + return isSentByMe + ? '🎙️ $durationLabel · $modeLabel$progress · ${_formatTransferCount(transferCount)} · $txEstimateLabel' + : '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel'; + } + return isSentByMe + ? '🎙️ $durationLabel · $modeLabel · ${_formatTransferCount(transferCount)} · $txEstimateLabel' + : '🎙️ $durationLabel · $modeLabel · $txEstimateLabel'; + } + + List _resolveWaveformBars({ + required VoiceSession? session, + required String messageText, + }) { + if (session != null) { + final fromSession = VoiceWaveform.buildBarsFromPackets(session.packets); + if (fromSession.any((v) => v > 0.0)) return fromSession; + } + + final legacyPacket = VoicePacket.tryParseText(messageText); + if (legacyPacket != null) { + final fromLegacy = VoiceWaveform.buildBarsFromPackets([legacyPacket]); + if (fromLegacy.any((v) => v > 0.0)) return fromLegacy; + } + + return const []; + } + + Duration _resolveVoiceTransmitEstimate({ + required VoiceSession? session, + required VoiceEnvelope? envelope, + required String messageText, + required int pathLen, + required int? radioBw, + required int? radioSf, + required int? radioCr, + }) { + if (session != null) { + final fromSession = estimateVoiceTransmitDurationFromPackets( + packets: session.packets, + pathLen: pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + if (fromSession > Duration.zero) return fromSession; + } + + if (envelope != null) { + return estimateVoiceTransmitDuration( + mode: envelope.mode, + packetCount: envelope.total, + durationMs: envelope.durationMs, + pathLen: pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + + final legacyPacket = VoicePacket.tryParseText(messageText); + if (legacyPacket != null) { + return estimateVoiceTransmitDuration( + mode: legacyPacket.mode, + packetCount: legacyPacket.total, + durationMs: legacyPacket.durationMs * legacyPacket.total, + pathLen: pathLen, + radioBw: radioBw, + radioSf: radioSf, + radioCr: radioCr, + ); + } + return Duration.zero; + } + + static String _formatTransmitEstimate(Duration value) { + if (value <= Duration.zero) return '~0s tx'; + if (value.inSeconds < 60) return '~${value.inSeconds}s tx'; + final minutes = value.inMinutes; + final seconds = value.inSeconds % 60; + return '~${minutes}m ${seconds}s tx'; + } + + static String _formatEta(Duration? eta) { + if (eta == null || eta <= Duration.zero) return 'ETA --'; + if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s'; + final minutes = eta.inMinutes; + final seconds = eta.inSeconds % 60; + return 'ETA ~${minutes}m ${seconds}s'; + } + + static String _formatTransferCount(int transferCount) { + return '$transferCount transfer${transferCount == 1 ? '' : 's'}'; + } + + bool _hasRecentInboundActivity({ + required DateTime? lastReceivedAt, + required int received, + required int total, + }) { + if (lastReceivedAt == null || received <= 0 || received >= total) { + return false; + } + return DateTime.now().difference(lastReceivedAt) <= + _recentInboundActivityWindow; + } +} + +class _PacketBlockProgress extends StatelessWidget { + final List presence; + final Color activeColor; + final bool highlightMissing; + + const _PacketBlockProgress({ + required this.presence, + required this.activeColor, + this.highlightMissing = false, + }); + + @override + Widget build(BuildContext context) { + if (presence.isEmpty) { + return const SizedBox(width: 100, height: 16); + } + + final bucketCount = presence.length <= 20 ? presence.length : 20; + final bucketFill = List.generate(bucketCount, (bucketIndex) { + final start = (bucketIndex * presence.length) ~/ bucketCount; + final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount; + final safeEnd = end <= start ? start + 1 : end; + final slice = presence.sublist(start, safeEnd); + final received = slice.where((value) => value).length; + return slice.isEmpty ? 0.0 : received / slice.length; + }); + final missingColor = highlightMissing + ? Colors.amberAccent + : Colors.white.withValues(alpha: 0.14); + + return SizedBox( + width: 100, + height: 16, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (final fill in bucketFill) + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: fill > 0 + ? activeColor.withValues(alpha: 0.18 + (0.72 * fill)) + : missingColor.withValues( + alpha: highlightMissing ? 0.45 : 0.14, + ), + borderRadius: BorderRadius.circular(2), + border: Border.all( + color: fill > 0 + ? Colors.white.withValues(alpha: 0.18) + : missingColor.withValues( + alpha: highlightMissing ? 0.7 : 0.18, + ), + width: 0.5, + ), + ), + ), + ), + ], + ), + ); + } +} + +/// Voice waveform rendered as a row of bars. +class _WaveformBar extends StatelessWidget { + final bool isComplete; + final List bars; + const _WaveformBar({required this.isComplete, required this.bars}); + + @override + Widget build(BuildContext context) { + final heights = bars.isEmpty + ? const [8.0, 12.0, 10.0, 14.0, 9.0, 12.0, 8.0, 11.0, 10.0, 13.0] + : bars.map((v) => 6.0 + (v.clamp(0.0, 1.0) * 14.0)).toList(); + final color = isComplete + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) + : Colors.grey.withValues(alpha: 0.5); + return Row( + children: heights + .map( + (h) => Container( + width: 3, + height: h, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + ) + .toList(), + ); + } +} diff --git a/lib/widgets/permission_request_dialog.dart b/lib/widgets/permission_request_dialog.dart new file mode 100644 index 0000000..1201762 --- /dev/null +++ b/lib/widgets/permission_request_dialog.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import '../l10n/app_localizations.dart'; + +/// Dialog that requests location permissions on app startup +class PermissionRequestDialog extends StatefulWidget { + final VoidCallback onPermissionsGranted; + final VoidCallback? onPermissionsDenied; + + const PermissionRequestDialog({ + super.key, + required this.onPermissionsGranted, + this.onPermissionsDenied, + }); + + @override + State createState() => _PermissionRequestDialogState(); +} + +class _PermissionRequestDialogState extends State { + bool _isRequesting = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + // Automatically check and request permissions when dialog opens + _checkAndRequestPermissions(); + } + + Future _checkAndRequestPermissions() async { + if (_isRequesting) return; + + setState(() { + _isRequesting = true; + _errorMessage = null; + }); + + try { + // Check if location service is enabled + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!mounted) return; + if (!serviceEnabled) { + setState(() { + _errorMessage = 'Location services are disabled. Please enable location services in your device settings.'; + _isRequesting = false; + }); + return; + } + + // Check current permission + LocationPermission permission = await Geolocator.checkPermission(); + if (!mounted) return; + + if (permission == LocationPermission.denied) { + // Request permission + permission = await Geolocator.requestPermission(); + if (!mounted) return; + } + + if (permission == LocationPermission.denied) { + setState(() { + _errorMessage = 'Location permission denied. This app requires location access to track your position and share it with your team.'; + _isRequesting = false; + }); + widget.onPermissionsDenied?.call(); + return; + } + + if (permission == LocationPermission.deniedForever) { + setState(() { + _errorMessage = 'Location permission permanently denied. Please enable location access in your device settings.'; + _isRequesting = false; + }); + widget.onPermissionsDenied?.call(); + return; + } + + // Permission granted! + setState(() { + _isRequesting = false; + }); + + // Close dialog and notify parent + Navigator.of(context).pop(); + widget.onPermissionsGranted(); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = 'Error requesting permissions: $e'; + _isRequesting = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return PopScope( + // Allow dismissing dialog by back button or tapping outside + canPop: true, + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + widget.onPermissionsDenied?.call(); + } + }, + child: AlertDialog( + title: Row( + children: [ + Icon( + Icons.location_on, + color: Theme.of(context).colorScheme.primary, + ), + SizedBox(width: 12), + Text(AppLocalizations.of(context)!.locationPermission), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'MeshCore SAR needs access to your location to:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + _buildPermissionReason( + icon: Icons.track_changes, + text: 'Track your position during search and rescue operations', + ), + const SizedBox(height: 8), + _buildPermissionReason( + icon: Icons.share_location, + text: 'Share your location with team members via mesh network', + ), + const SizedBox(height: 8), + _buildPermissionReason( + icon: Icons.map, + text: 'Display your location and trail on the map', + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ), + ], + ), + ), + ], + if (_isRequesting) ...[ + const SizedBox(height: 16), + const Center( + child: CircularProgressIndicator(), + ), + ], + ], + ), + actions: [ + // Always show a cancel/skip button + TextButton( + onPressed: () { + Navigator.of(context).pop(); + widget.onPermissionsDenied?.call(); + }, + child: Text(AppLocalizations.of(context)!.skip), + ), + if (_errorMessage != null && !_isRequesting) + ElevatedButton( + onPressed: () async { + // Open app settings + await Geolocator.openLocationSettings(); + }, + child: Text(AppLocalizations.of(context)!.openSettings), + ), + if (_errorMessage != null && !_isRequesting && + !_errorMessage!.contains('permanently denied')) + ElevatedButton( + onPressed: _checkAndRequestPermissions, + child: Text(AppLocalizations.of(context)!.retry), + ), + ], + ), + ); + } + + Widget _buildPermissionReason({ + required IconData icon, + required String text, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: const TextStyle(fontSize: 14), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/sar/sar_template_edit_dialog.dart b/lib/widgets/sar/sar_template_edit_dialog.dart new file mode 100644 index 0000000..1dff480 --- /dev/null +++ b/lib/widgets/sar/sar_template_edit_dialog.dart @@ -0,0 +1,321 @@ +import 'package:flutter/material.dart'; +import '../../models/sar_template.dart'; +import '../../l10n/app_localizations.dart'; + +/// Dialog for adding or editing SAR templates +class SarTemplateEditDialog extends StatefulWidget { + final SarTemplate? template; // Null for new template + final Function(SarTemplate) onSave; + + const SarTemplateEditDialog({ + super.key, + this.template, + required this.onSave, + }); + + @override + State createState() => _SarTemplateEditDialogState(); +} + +class _SarTemplateEditDialogState extends State { + late TextEditingController _emojiController; + late TextEditingController _nameController; + late TextEditingController _descriptionController; + late String _selectedColor; + + final List> _colorOptions = [ + {'name': 'Green', 'hex': '#4CAF50'}, + {'name': 'Red', 'hex': '#F44336'}, + {'name': 'Orange', 'hex': '#FF9800'}, + {'name': 'Purple', 'hex': '#9C27B0'}, + {'name': 'Blue', 'hex': '#2196F3'}, + {'name': 'Yellow', 'hex': '#FFC107'}, + {'name': 'Brown', 'hex': '#795548'}, + {'name': 'Gray', 'hex': '#9E9E9E'}, + ]; + + String? _emojiError; + String? _nameError; + + @override + void initState() { + super.initState(); + _emojiController = TextEditingController(text: widget.template?.emoji ?? ''); + _nameController = TextEditingController(text: widget.template?.name ?? ''); + _descriptionController = TextEditingController(text: widget.template?.description ?? ''); + _selectedColor = widget.template?.colorHex ?? '#4CAF50'; + } + + @override + void dispose() { + _emojiController.dispose(); + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + bool _validate() { + final l10n = AppLocalizations.of(context)!; + + setState(() { + _emojiError = null; + _nameError = null; + }); + + bool isValid = true; + + if (_emojiController.text.trim().isEmpty) { + setState(() { + _emojiError = l10n.emojiRequired; + }); + isValid = false; + } + + if (_nameController.text.trim().isEmpty) { + setState(() { + _nameError = l10n.nameRequired; + }); + isValid = false; + } + + return isValid; + } + + void _save() { + if (!_validate()) return; + + final template = SarTemplate( + id: widget.template?.id ?? 'custom_${DateTime.now().millisecondsSinceEpoch}', + emoji: _emojiController.text.trim(), + name: _nameController.text.trim(), + description: _descriptionController.text.trim(), + colorHex: _selectedColor, + isDefault: widget.template?.isDefault ?? false, + ); + + widget.onSave(template); + Navigator.of(context).pop(); + } + + String _getPreview() { + final emoji = _emojiController.text.trim(); + final description = _descriptionController.text.trim(); + if (emoji.isEmpty) return 'S::0,0'; + if (description.isEmpty) return 'S:$emoji:0,0'; + return 'S:$emoji:0,0:$description'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + final bottomPadding = MediaQuery.of(context).viewInsets.bottom; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return SingleChildScrollView( + controller: scrollController, + child: Padding( + padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottomPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header + Text( + widget.template == null ? l10n.addTemplate : l10n.editTemplate, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 24), + + // Emoji field + TextField( + controller: _emojiController, + decoration: InputDecoration( + labelText: l10n.templateEmoji, + hintText: '🧑', + errorText: _emojiError, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.emoji_emotions), + ), + maxLength: 4, + style: const TextStyle(fontSize: 24), + textAlign: TextAlign.center, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + + // Name field + TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.templateName, + hintText: l10n.templateNameHint, + errorText: _nameError, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.label), + ), + maxLength: 30, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + + // Description field + TextField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: l10n.templateDescription, + hintText: l10n.templateDescriptionHint, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.description), + ), + maxLength: 100, + maxLines: 2, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: 16), + + // Color picker + Text( + l10n.templateColor, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: _colorOptions.map((colorOption) { + final hex = colorOption['hex'] as String; + final color = Color(int.parse('FF${hex.replaceAll('#', '')}', radix: 16)); + final isSelected = _selectedColor == hex; + + return GestureDetector( + onTap: () => setState(() => _selectedColor = hex), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: isSelected ? colorScheme.primary : Colors.transparent, + width: 3, + ), + boxShadow: [ + if (isSelected) + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.3), + blurRadius: 8, + spreadRadius: 2, + ), + ], + ), + child: isSelected + ? const Icon(Icons.check, color: Colors.white) + : null, + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + + // Preview + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.previewFormat, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + _getPreview(), + style: TextStyle( + fontFamily: 'monospace', + fontSize: 14, + color: colorScheme.onSurface, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: _save, + icon: Icon(Icons.save), + label: Text(l10n.save), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/sensors/bthome_met_history_sheet.dart b/lib/widgets/sensors/bthome_met_history_sheet.dart new file mode 100644 index 0000000..1430867 --- /dev/null +++ b/lib/widgets/sensors/bthome_met_history_sheet.dart @@ -0,0 +1,820 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import '../../l10n/app_localizations.dart'; +import 'package:meshcore_client/meshcore_client.dart' show Message; +import 'package:provider/provider.dart'; + +import '../../models/contact.dart'; +import '../../providers/connection_provider.dart'; +import '../../services/bthome_met_history.dart'; + +Future showBTHomeMetHistorySheet( + BuildContext context, { + required Contact contact, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (sheetContext) => _BTHomeMetHistorySheet(contact: contact), + ); +} + +class _BTHomeMetHistorySheet extends StatefulWidget { + const _BTHomeMetHistorySheet({required this.contact}); + + final Contact contact; + + @override + State<_BTHomeMetHistorySheet> createState() => _BTHomeMetHistorySheetState(); +} + +class _BTHomeMetHistorySheetState extends State<_BTHomeMetHistorySheet> { + late final List _availableMeasurements; + late BTHomeMetMeasurement _selectedMeasurement; + BTHomeMetHistoryPage? _history; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _availableMeasurements = bTHomeMetMeasurementsForContact(widget.contact); + _selectedMeasurement = _availableMeasurements.isEmpty + ? BTHomeMetMeasurement.temperature + : _availableMeasurements.first; + if (_availableMeasurements.isEmpty) { + _loading = false; + _error = 'No BTHome MET-compatible telemetry is available for this node.'; + return; + } + unawaited(_loadHistory(measurement: _selectedMeasurement, page: 0)); + } + + Future _loadHistory({ + required BTHomeMetMeasurement measurement, + required int page, + }) async { + final connectionProvider = context.read(); + final previousOnMessageReceived = connectionProvider.onMessageReceived; + String? responseText; + + void onMessage(Message message) { + previousOnMessageReceived?.call(message); + if (_matchesContact(message)) { + responseText = message.text; + } + } + + connectionProvider.onMessageReceived = onMessage; + + if (mounted) { + setState(() { + _loading = true; + _error = null; + }); + } + + try { + final sent = await connectionProvider.sendTextMessage( + contactPublicKey: widget.contact.publicKey, + text: 'bthome met history ${measurement.id} $page', + ); + if (!sent) { + throw Exception('Failed to send MET history request.'); + } + + for (var i = 0; i < 30; i++) { + await Future.delayed(const Duration(milliseconds: 500)); + if (responseText != null) { + break; + } + } + + final text = responseText?.trim(); + if (text == null || text.isEmpty) { + throw TimeoutException('No response from sensor.'); + } + if (_looksLikeError(text)) { + throw Exception(text); + } + + final history = BTHomeMetHistoryParser.parse(text); + if (!mounted) { + return; + } + + setState(() { + _selectedMeasurement = measurement; + _history = history; + _loading = false; + _error = null; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _selectedMeasurement = measurement; + _history = null; + _loading = false; + _error = _formatError(error); + }); + } finally { + if (identical(connectionProvider.onMessageReceived, onMessage)) { + connectionProvider.onMessageReceived = previousOnMessageReceived; + } + } + } + + bool _matchesContact(Message message) { + final prefix = message.senderPublicKeyPrefix; + if (prefix == null || + prefix.length < 6 || + widget.contact.publicKey.length < 6) { + return false; + } + for (var i = 0; i < 6; i++) { + if (prefix[i] != widget.contact.publicKey[i]) { + return false; + } + } + return true; + } + + bool _looksLikeError(String text) { + final lower = text.toLowerCase(); + return lower.startsWith('err') || + lower.contains('unknown') || + lower.contains('unsupported'); + } + + String _formatError(Object error) { + if (error is TimeoutException) { + return error.message ?? 'Timed out waiting for MET history.'; + } + return error.toString().replaceFirst('Exception: ', ''); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final height = MediaQuery.of(context).size.height * 0.8; + final history = _history; + + return SafeArea( + child: SizedBox( + height: height, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'MET history', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + widget.contact.displayName, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: _availableMeasurements + .map( + (measurement) => ChoiceChip( + label: Text(measurement.label), + selected: measurement == _selectedMeasurement, + onSelected: (selected) { + if (!selected || _loading) { + return; + } + unawaited( + _loadHistory(measurement: measurement, page: 0), + ); + }, + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 16), + Row( + children: [ + OutlinedButton.icon( + onPressed: _loading || (history?.page ?? 0) == 0 + ? null + : () => unawaited( + _loadHistory( + measurement: _selectedMeasurement, + page: history!.page - 1, + ), + ), + icon: const Icon(Icons.chevron_left), + label: Text(AppLocalizations.of(context)!.newer), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: + _loading || + history == null || + history.values.length < 12 + ? null + : () => unawaited( + _loadHistory( + measurement: _selectedMeasurement, + page: history.page + 1, + ), + ), + icon: const Icon(Icons.chevron_right), + label: Text(AppLocalizations.of(context)!.older), + ), + const Spacer(), + if (_loading) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Text( + 'Page ${history?.page ?? 0}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 12), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_error != null) + _HistoryMessageCard( + icon: Icons.error_outline, + title: AppLocalizations.of(context)!.couldNotLoadMetHistory, + body: _error!, + ) + else if (_loading && history == null) + _HistoryMessageCard( + icon: Icons.hourglass_top, + title: AppLocalizations.of(context)!.loading, + body: 'Waiting for the sensor to reply.', + ) + else if (history != null) ...[ + _HistoryChartCard(history: history), + const SizedBox(height: 12), + _HistoryStatsGrid(history: history), + const SizedBox(height: 12), + _HistorySamplesCard(history: history), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _HistoryChartCard extends StatelessWidget { + const _HistoryChartCard({required this.history}); + + final BTHomeMetHistoryPage history; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + history.measurement.label, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Samples are shown oldest to newest. Firmware replies do not include timestamps.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 180, + child: LineChart(_historyLineChartData(context, history: history)), + ), + ], + ), + ); + } +} + +class _HistoryStatsGrid extends StatelessWidget { + const _HistoryStatsGrid({required this.history}); + + final BTHomeMetHistoryPage history; + + @override + Widget build(BuildContext context) { + final measurement = history.measurement; + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 520; + final tiles = [ + _HistoryStatTile( + label: AppLocalizations.of(context)!.latest, + value: _formatMeasurementValue(measurement, history.latest), + ), + _HistoryStatTile( + label: AppLocalizations.of(context)!.min, + value: _formatMeasurementValue(measurement, history.minimum), + ), + _HistoryStatTile( + label: AppLocalizations.of(context)!.max, + value: _formatMeasurementValue(measurement, history.maximum), + ), + _HistoryStatTile( + label: AppLocalizations.of(context)!.samples, + value: history.values.length.toString(), + ), + ]; + + if (compact) { + return Column( + children: [ + Row( + children: [ + Expanded(child: tiles[0]), + const SizedBox(width: 8), + Expanded(child: tiles[1]), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: tiles[2]), + const SizedBox(width: 8), + Expanded(child: tiles[3]), + ], + ), + ], + ); + } + + return Row( + children: [ + Expanded(child: tiles[0]), + const SizedBox(width: 8), + Expanded(child: tiles[1]), + const SizedBox(width: 8), + Expanded(child: tiles[2]), + const SizedBox(width: 8), + Expanded(child: tiles[3]), + ], + ); + }, + ); + } +} + +class _HistoryStatTile extends StatelessWidget { + const _HistoryStatTile({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _HistorySamplesCard extends StatelessWidget { + const _HistorySamplesCard({required this.history}); + + final BTHomeMetHistoryPage history; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Samples', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: history.values + .asMap() + .entries + .map((entry) { + final sampleIndex = entry.key + 1; + final isLatest = entry.key == history.values.length - 1; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: isLatest + ? _historyColor( + history.measurement, + ).withValues(alpha: 0.12) + : theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(14), + ), + child: Text( + '$sampleIndex. ${_formatMeasurementValue(history.measurement, entry.value)}${isLatest ? ' latest' : ''}', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: isLatest + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + ); + }) + .toList(growable: false), + ), + ], + ), + ); + } +} + +class _HistoryMessageCard extends StatelessWidget { + const _HistoryMessageCard({ + required this.icon, + required this.title, + required this.body, + }); + + final IconData icon; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text(body), + ], + ), + ), + ], + ), + ); + } +} + +Color _historyColor(BTHomeMetMeasurement measurement) { + switch (measurement) { + case BTHomeMetMeasurement.temperature: + return const Color(0xFFC76821); + case BTHomeMetMeasurement.humidity: + return const Color(0xFF246BB2); + case BTHomeMetMeasurement.windSpeed: + return const Color(0xFF2B78A0); + case BTHomeMetMeasurement.gust: + return const Color(0xFF1E88A8); + case BTHomeMetMeasurement.rain: + return const Color(0xFF2C6BA0); + } +} + +String _formatMeasurementValue(BTHomeMetMeasurement measurement, num? value) { + if (value == null) { + return '--'; + } + + final digits = switch (measurement) { + BTHomeMetMeasurement.humidity => 0, + BTHomeMetMeasurement.temperature => 1, + BTHomeMetMeasurement.windSpeed => 1, + BTHomeMetMeasurement.gust => 1, + BTHomeMetMeasurement.rain => 1, + }; + + final text = value + .toStringAsFixed(digits) + .replaceFirst(RegExp(r'\.?0+$'), ''); + return '$text${measurement.unit}'; +} + +LineChartData _historyLineChartData( + BuildContext context, { + required BTHomeMetHistoryPage history, +}) { + final theme = Theme.of(context); + final color = _historyColor(history.measurement); + final values = history.values; + final spots = values + .asMap() + .entries + .map((entry) => FlSpot(entry.key.toDouble(), entry.value)) + .toList(growable: false); + final chartMinY = _historyChartMinY(history); + final chartMaxY = _historyChartMaxY(history); + final yInterval = _historyYAxisInterval( + measurement: history.measurement, + minY: chartMinY, + maxY: chartMaxY, + ); + + return LineChartData( + minX: 0, + maxX: values.length <= 1 ? 1.0 : (values.length - 1).toDouble(), + minY: chartMinY, + maxY: chartMaxY, + clipData: const FlClipData.all(), + lineTouchData: const LineTouchData(enabled: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: yInterval, + getDrawingHorizontalLine: (value) => FlLine( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.28), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 42, + interval: yInterval, + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + space: 8, + child: Text( + _formatHistoryAxisValue(history.measurement, value), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 24, + interval: 1, + getTitlesWidget: (value, meta) { + final index = value.round(); + if (value != index.toDouble() || + !_shouldShowBottomSampleLabel(index, values.length)) { + return const SizedBox.shrink(); + } + + return SideTitleWidget( + meta: meta, + space: 6, + child: Text( + '${index + 1}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: spots, + color: color, + barWidth: 2.8, + isCurved: false, + isStrokeCapRound: true, + dotData: FlDotData( + show: true, + checkToShowDot: (spot, barData) => + barData.spots.length <= 10 || spot == barData.spots.last, + getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter( + radius: spot == barData.spots.last ? 4 : 2.5, + color: color, + strokeColor: theme.colorScheme.surface, + strokeWidth: 1.6, + ), + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + color.withValues(alpha: 0.24), + color.withValues(alpha: 0.03), + ], + ), + ), + ), + ], + ); +} + +double _historyChartMinY(BTHomeMetHistoryPage history) { + final minValue = history.minimum ?? 0; + final maxValue = history.maximum ?? 0; + final spread = maxValue - minValue; + final padding = spread == 0 + ? _historyMinimumPadding(history.measurement, minValue) + : math.max( + spread * 0.18, + _historyMinimumPadding(history.measurement, minValue), + ); + return minValue - padding; +} + +double _historyChartMaxY(BTHomeMetHistoryPage history) { + final minValue = history.minimum ?? 0; + final maxValue = history.maximum ?? 0; + final spread = maxValue - minValue; + final padding = spread == 0 + ? _historyMinimumPadding(history.measurement, maxValue) + : math.max( + spread * 0.18, + _historyMinimumPadding(history.measurement, maxValue), + ); + return maxValue + padding; +} + +double _historyMinimumPadding( + BTHomeMetMeasurement measurement, + double reference, +) { + final scaled = math.max(reference.abs() * 0.05, 0.1); + return switch (measurement) { + BTHomeMetMeasurement.temperature => math.max(0.4, scaled), + BTHomeMetMeasurement.humidity => math.max(2.0, scaled), + BTHomeMetMeasurement.windSpeed => math.max(0.4, scaled), + BTHomeMetMeasurement.gust => math.max(0.4, scaled), + BTHomeMetMeasurement.rain => math.max(0.4, scaled), + }; +} + +double _historyYAxisInterval({ + required BTHomeMetMeasurement measurement, + required double minY, + required double maxY, +}) { + final span = maxY - minY; + if (span <= 0) { + return 1; + } + + final rough = span / 3; + return switch (measurement) { + BTHomeMetMeasurement.humidity => math.max(1, rough.round()).toDouble(), + BTHomeMetMeasurement.temperature => _niceStep(rough, minStep: 0.5), + BTHomeMetMeasurement.windSpeed => _niceStep(rough, minStep: 0.5), + BTHomeMetMeasurement.gust => _niceStep(rough, minStep: 0.5), + BTHomeMetMeasurement.rain => _niceStep(rough, minStep: 0.5), + }; +} + +double _niceStep(double value, {required double minStep}) { + if (value <= minStep) { + return minStep; + } + + final exponent = math + .pow(10.0, (math.log(value) / math.ln10).floor()) + .toDouble(); + final normalized = value / exponent; + final stepped = switch (normalized) { + < 1.5 => 1.0, + < 3.0 => 2.0, + < 7.0 => 5.0, + _ => 10.0, + }; + return math.max(minStep, stepped * exponent); +} + +String _formatHistoryAxisValue(BTHomeMetMeasurement measurement, double value) { + final digits = measurement == BTHomeMetMeasurement.humidity ? 0 : 1; + return value.toStringAsFixed(digits).replaceFirst(RegExp(r'\.?0+$'), ''); +} + +bool _shouldShowBottomSampleLabel(int index, int sampleCount) { + if (index < 0 || index >= sampleCount) { + return false; + } + if (sampleCount <= 3) { + return true; + } + + final middle = (sampleCount - 1) ~/ 2; + return index == 0 || index == middle || index == sampleCount - 1; +} diff --git a/lib/widgets/sensors/sensor_history_sheet.dart b/lib/widgets/sensors/sensor_history_sheet.dart new file mode 100644 index 0000000..25ae8eb --- /dev/null +++ b/lib/widgets/sensors/sensor_history_sheet.dart @@ -0,0 +1,646 @@ +import 'dart:math' as math; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../providers/connection_provider.dart'; +import '../../providers/contacts_provider.dart'; +import '../../providers/sensors_provider.dart'; +import 'sensor_telemetry_card.dart'; + +enum SensorHistoryRange { day, week, month, all } + +Future showSensorHistorySheet( + BuildContext context, { + required String publicKeyHex, + String? initialFieldKey, +}) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (pageContext) => SensorHistoryScreen( + publicKeyHex: publicKeyHex, + initialFieldKey: initialFieldKey, + ), + ), + ); +} + +class SensorHistoryScreen extends StatefulWidget { + const SensorHistoryScreen({ + super.key, + required this.publicKeyHex, + this.initialFieldKey, + }); + + final String publicKeyHex; + final String? initialFieldKey; + + @override + State createState() => _SensorHistoryScreenState(); +} + +class _SensorHistoryScreenState extends State { + late SensorHistoryRange _selectedRange; + + @override + void initState() { + super.initState(); + _selectedRange = SensorHistoryRange.day; + } + + @override + Widget build(BuildContext context) { + return Consumer3( + builder: + ( + context, + sensorsProvider, + contactsProvider, + connectionProvider, + child, + ) { + final contact = sensorsProvider.contactForDisplay( + widget.publicKeyHex, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + final history = sensorsProvider.historyFor(widget.publicKeyHex); + final options = sensorMetricOptionsFor( + contact, + labelOverrides: sensorsProvider.labelOverridesFor( + widget.publicKeyHex, + ), + ); + final optionByKey = { + for (final option in options) option.key: option, + }; + final availableFieldKeys = { + for (final sample in history) ...sample.values.keys, + }.toList() + ..sort((a, b) { + final aIndex = options.indexWhere((option) => option.key == a); + final bIndex = options.indexWhere((option) => option.key == b); + if (aIndex == -1 && bIndex == -1) { + return a.compareTo(b); + } + if (aIndex == -1) { + return 1; + } + if (bIndex == -1) { + return -1; + } + return aIndex.compareTo(bIndex); + }); + + final selectedFieldKey = resolveInitialSensorHistoryField( + requestedFieldKey: widget.initialFieldKey, + availableFieldKeys: availableFieldKeys, + ); + final selectedOption = selectedFieldKey == null + ? null + : optionByKey[selectedFieldKey]; + final selectedCardData = selectedOption?.previewCardData; + final selectedSamples = selectedFieldKey == null + ? const [] + : history + .where( + (sample) => + sample.values.containsKey(selectedFieldKey), + ) + .toList(growable: false); + final rangeSamples = filterSensorHistorySamples( + samples: selectedSamples, + range: _selectedRange, + ); + final title = + selectedCardData?.label ?? + selectedOption?.defaultLabel ?? + 'Sensor history'; + + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + Text( + contact?.displayName ?? 'Unavailable node', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + bottom: const TabBar( + tabs: [ + Tab(text: 'Graph'), + Tab(text: 'Values'), + ], + ), + ), + body: selectedFieldKey == null + ? _SensorHistoryEmptyState( + message: + 'No history recorded yet. Enable auto refresh for this sensor and leave the app running to collect samples.', + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: SensorHistoryRange.values + .map( + (range) => ChoiceChip( + label: Text( + sensorHistoryRangeLabel(range), + ), + selected: _selectedRange == range, + onSelected: (selected) { + if (!selected) { + return; + } + setState(() { + _selectedRange = range; + }); + }, + ), + ) + .toList(growable: false), + ), + ), + Expanded( + child: TabBarView( + children: [ + _SensorHistoryGraphTab( + samples: rangeSamples, + fieldKey: selectedFieldKey, + cardData: selectedCardData, + totalCount: selectedSamples.length, + range: _selectedRange, + ), + _SensorHistoryValuesTab( + samples: rangeSamples, + fieldKey: selectedFieldKey, + cardData: selectedCardData, + range: _selectedRange, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +String? resolveInitialSensorHistoryField({ + required String? requestedFieldKey, + required List availableFieldKeys, +}) { + if (requestedFieldKey != null && + availableFieldKeys.contains(requestedFieldKey)) { + return requestedFieldKey; + } + if (availableFieldKeys.isEmpty) { + return null; + } + return availableFieldKeys.first; +} + +List filterSensorHistorySamples({ + required List samples, + required SensorHistoryRange range, +}) { + if (samples.isEmpty || range == SensorHistoryRange.all) { + return List.from(samples); + } + + final latestTimestamp = samples.last.timestamp; + final cutoff = switch (range) { + SensorHistoryRange.day => latestTimestamp.subtract(const Duration(days: 1)), + SensorHistoryRange.week => latestTimestamp.subtract(const Duration(days: 7)), + SensorHistoryRange.month => latestTimestamp.subtract( + const Duration(days: 30), + ), + SensorHistoryRange.all => DateTime.fromMillisecondsSinceEpoch(0), + }; + + return samples + .where((sample) => !sample.timestamp.isBefore(cutoff)) + .toList(growable: false); +} + +String sensorHistoryRangeLabel(SensorHistoryRange range) { + return switch (range) { + SensorHistoryRange.day => '24h', + SensorHistoryRange.week => '7d', + SensorHistoryRange.month => '30d', + SensorHistoryRange.all => 'All', + }; +} + +class _SensorHistoryGraphTab extends StatelessWidget { + const _SensorHistoryGraphTab({ + required this.samples, + required this.fieldKey, + required this.cardData, + required this.totalCount, + required this.range, + }); + + final List samples; + final String fieldKey; + final SensorMetricCardData? cardData; + final int totalCount; + final SensorHistoryRange range; + + @override + Widget build(BuildContext context) { + if (samples.isEmpty) { + return _SensorHistoryEmptyState( + message: + 'No samples are available for ${sensorHistoryRangeLabel(range)}.', + ); + } + + final latestValue = samples.last.values[fieldKey]!; + final minValue = samples + .map((sample) => sample.values[fieldKey]!) + .reduce(math.min); + final maxValue = samples + .map((sample) => sample.values[fieldKey]!) + .reduce(math.max); + final accent = cardData?.accent ?? Theme.of(context).colorScheme.primary; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Expanded( + child: _SensorHistoryStatTile( + label: 'Visible', + value: samples.length.toString(), + accent: accent, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SensorHistoryStatTile( + label: 'Latest', + value: _formatHistoryValue(cardData, latestValue), + accent: accent, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SensorHistoryStatTile( + label: 'Min', + value: _formatHistoryValue(cardData, minValue), + accent: accent, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SensorHistoryStatTile( + label: 'Max', + value: _formatHistoryValue(cardData, maxValue), + accent: accent, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '$totalCount total readings', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: SizedBox( + height: 280, + child: LineChart( + _historyLineChartData( + context, + samples: samples, + fieldKey: fieldKey, + color: accent, + ), + duration: Duration.zero, + ), + ), + ), + ], + ); + } +} + +class _SensorHistoryValuesTab extends StatelessWidget { + const _SensorHistoryValuesTab({ + required this.samples, + required this.fieldKey, + required this.cardData, + required this.range, + }); + + final List samples; + final String fieldKey; + final SensorMetricCardData? cardData; + final SensorHistoryRange range; + + @override + Widget build(BuildContext context) { + if (samples.isEmpty) { + return _SensorHistoryEmptyState( + message: + 'No values are available for ${sensorHistoryRangeLabel(range)}.', + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: samples.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final sample = samples[samples.length - index - 1]; + final value = sample.values[fieldKey]!; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + _formatSampleTimestamp(sample.timestamp), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + _formatHistoryValue(cardData, value), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: + cardData?.accent ?? Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _SensorHistoryEmptyState extends StatelessWidget { + const _SensorHistoryEmptyState({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ); + } +} + +class _SensorHistoryStatTile extends StatelessWidget { + const _SensorHistoryStatTile({ + required this.label, + required this.value, + required this.accent, + }); + + final String label; + final String value; + final Color accent; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} + +LineChartData _historyLineChartData( + BuildContext context, { + required List samples, + required String fieldKey, + required Color color, +}) { + final theme = Theme.of(context); + final values = samples + .map((sample) => sample.values[fieldKey]!) + .toList(growable: false); + final spots = values + .asMap() + .entries + .map((entry) => FlSpot(entry.key.toDouble(), entry.value)) + .toList(growable: false); + final minValue = values.reduce(math.min); + final maxValue = values.reduce(math.max); + final spread = maxValue - minValue; + final padding = spread == 0 + ? math.max(maxValue.abs() * 0.1, 1.0) + : spread * 0.15; + final minY = minValue - padding; + final maxY = maxValue + padding; + final interval = spread <= 0 + ? math.max(maxValue.abs() / 3, 1.0) + : spread / 3; + + return LineChartData( + minX: 0, + maxX: values.length <= 1 ? 1.0 : (values.length - 1).toDouble(), + minY: minY, + maxY: maxY, + clipData: const FlClipData.all(), + lineTouchData: const LineTouchData(enabled: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: interval, + getDrawingHorizontalLine: (value) => FlLine( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.24), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: interval, + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + space: 8, + child: Text( + value.toStringAsFixed(value.abs() >= 10 ? 0 : 1), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 24, + interval: math.max((values.length / 4).floorToDouble(), 1), + getTitlesWidget: (value, meta) { + final index = value.round(); + if (index < 0 || index >= samples.length || value != index.toDouble()) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + meta: meta, + space: 6, + child: Text( + _formatChartTimestamp(samples[index].timestamp), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: spots, + color: color, + barWidth: 2.8, + isCurved: false, + dotData: FlDotData( + show: true, + checkToShowDot: (spot, barData) => + barData.spots.length <= 10 || spot == barData.spots.last, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + color.withValues(alpha: 0.20), + color.withValues(alpha: 0.03), + ], + ), + ), + ), + ], + ); +} + +String _formatHistoryValue(SensorMetricCardData? cardData, double value) { + final template = cardData?.value; + if (template == null || template.isEmpty) { + return value.toStringAsFixed(value.abs() >= 10 ? 0 : 1); + } + + if (template.endsWith('%')) { + return '${value.toStringAsFixed(1)}%'; + } + if (template.endsWith('V')) { + return '${value.toStringAsFixed(3)}V'; + } + if (template.contains(' hPa')) { + return '${value.toStringAsFixed(1)} hPa'; + } + if (template.contains('°C')) { + return '${value.toStringAsFixed(1)}°C'; + } + if (template.contains(' mph')) { + return '${value.toStringAsFixed(2)} mph'; + } + if (template.contains(' km/h')) { + return '${value.toStringAsFixed(2)} km/h'; + } + return value.toStringAsFixed(value.abs() >= 10 ? 0 : 1); +} + +String _formatSampleTimestamp(DateTime timestamp) { + final local = timestamp.toLocal(); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final year = local.year.toString().substring(2); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '$month/$day/$year $hour:$minute'; +} + +String _formatChartTimestamp(DateTime timestamp) { + final local = timestamp.toLocal(); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + return '$month/$day'; +} diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart new file mode 100644 index 0000000..a811791 --- /dev/null +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -0,0 +1,3393 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:latlong2/latlong.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; +import '../../providers/sensors_provider.dart'; +import '../../services/bthome_met_history.dart'; +import '../../utils/location_formats.dart'; + +class SensorMetricOption { + final String key; + final String label; + final String defaultLabel; + final int? channel; + final String? valuePreview; + final SensorMetricCardData? previewCardData; + + const SensorMetricOption({ + required this.key, + required this.label, + required this.defaultLabel, + this.channel, + this.valuePreview, + this.previewCardData, + }); +} + +const Set _imperialMeasurementCountries = { + 'US', + 'LR', + 'MM', +}; + +bool _usesImperialSpeedUnits() { + final countryCode = + WidgetsBinding.instance.platformDispatcher.locale.countryCode; + if (countryCode == null || countryCode.isEmpty) { + return false; + } + return _imperialMeasurementCountries.contains(countryCode.toUpperCase()); +} + +String _formatPreviewSpeed(num metersPerSecond) { + if (_usesImperialSpeedUnits()) { + final milesPerHour = metersPerSecond * 2.2369362920544; + return '${_formatPreviewNumber(milesPerHour, maxFractionDigits: 2)} mph'; + } + + final kilometersPerHour = metersPerSecond * 3.6; + return '${_formatPreviewNumber(kilometersPerHour, maxFractionDigits: 2)} km/h'; +} + +List sensorMetricOptionsFor( + Contact? contact, { + Map labelOverrides = const {}, +}) { + final telemetry = contact?.telemetry; + final extraSensorData = telemetry?.extraSensorData; + final batteryMilliVolts = telemetry?.batteryMilliVolts; + final batteryPercentage = telemetry?.batteryPercentage; + final temperature = telemetry?.temperature; + final humidity = telemetry?.humidity; + final pressure = telemetry?.pressure; + final gpsLocation = telemetry?.gpsLocation; + final options = [ + if (batteryMilliVolts != null) + SensorMetricOption( + key: 'voltage', + label: _selectorMetricLabel( + _resolvedMetricLabel( + 'voltage', + 'Voltage', + labelOverrides: labelOverrides, + ), + _sourceChannelForField(extraSensorData, 'voltage'), + ), + defaultLabel: 'Voltage', + channel: _sourceChannelForField(extraSensorData, 'voltage'), + valuePreview: '${(batteryMilliVolts / 1000).toStringAsFixed(3)}V', + previewCardData: SensorMetricCardData( + fieldKey: 'voltage', + icon: Icons.bolt, + label: _resolvedMetricLabel( + 'voltage', + 'Voltage', + labelOverrides: labelOverrides, + ), + value: '${(batteryMilliVolts / 1000).toStringAsFixed(3)}V', + accent: const Color(0xFF0A7D61), + channel: _sourceChannelForField(extraSensorData, 'voltage'), + ), + ), + if (batteryPercentage != null) + SensorMetricOption( + key: 'battery', + label: _selectorMetricLabel( + _resolvedMetricLabel( + 'battery', + 'Battery', + labelOverrides: labelOverrides, + ), + _sourceChannelForField(extraSensorData, 'battery'), + ), + defaultLabel: 'Battery', + channel: _sourceChannelForField(extraSensorData, 'battery'), + valuePreview: '${batteryPercentage.toStringAsFixed(0)}%', + previewCardData: SensorMetricCardData( + fieldKey: 'battery', + icon: Icons.battery_5_bar, + label: _resolvedMetricLabel( + 'battery', + 'Battery', + labelOverrides: labelOverrides, + ), + value: '${batteryPercentage.toStringAsFixed(0)}%', + accent: const Color(0xFF4B8E2F), + channel: _sourceChannelForField(extraSensorData, 'battery'), + ), + ), + if (temperature != null) + SensorMetricOption( + key: 'temperature', + label: _selectorMetricLabel( + _resolvedMetricLabel( + 'temperature', + 'Temperature', + labelOverrides: labelOverrides, + ), + _sourceChannelForField(extraSensorData, 'temperature'), + ), + defaultLabel: 'Temperature', + channel: _sourceChannelForField(extraSensorData, 'temperature'), + valuePreview: '${temperature.toStringAsFixed(1)}°C', + previewCardData: SensorMetricCardData( + fieldKey: 'temperature', + icon: Icons.thermostat, + label: _resolvedMetricLabel( + 'temperature', + 'Temperature', + labelOverrides: labelOverrides, + ), + value: '${temperature.toStringAsFixed(1)}°C', + accent: const Color(0xFFC76821), + channel: _sourceChannelForField(extraSensorData, 'temperature'), + ), + ), + if (humidity != null) + SensorMetricOption( + key: 'humidity', + label: _selectorMetricLabel( + _resolvedMetricLabel( + 'humidity', + 'Humidity', + labelOverrides: labelOverrides, + ), + _sourceChannelForField(extraSensorData, 'humidity'), + ), + defaultLabel: 'Humidity', + channel: _sourceChannelForField(extraSensorData, 'humidity'), + valuePreview: '${humidity.toStringAsFixed(1)}%', + previewCardData: SensorMetricCardData( + fieldKey: 'humidity', + icon: Icons.water_drop, + label: _resolvedMetricLabel( + 'humidity', + 'Humidity', + labelOverrides: labelOverrides, + ), + value: '${humidity.toStringAsFixed(1)}%', + accent: const Color(0xFF246BB2), + channel: _sourceChannelForField(extraSensorData, 'humidity'), + ), + ), + if (pressure != null) + SensorMetricOption( + key: 'pressure', + label: _selectorMetricLabel( + _resolvedMetricLabel( + 'pressure', + 'Pressure', + labelOverrides: labelOverrides, + ), + _sourceChannelForField(extraSensorData, 'pressure'), + ), + defaultLabel: 'Pressure', + channel: _sourceChannelForField(extraSensorData, 'pressure'), + valuePreview: '${pressure.toStringAsFixed(1)} hPa', + previewCardData: SensorMetricCardData( + fieldKey: 'pressure', + icon: Icons.compress, + label: _resolvedMetricLabel( + 'pressure', + 'Pressure', + labelOverrides: labelOverrides, + ), + value: '${pressure.toStringAsFixed(1)} hPa', + accent: const Color(0xFF6B4BAE), + channel: _sourceChannelForField(extraSensorData, 'pressure'), + ), + ), + if (gpsLocation != null) + SensorMetricOption( + key: 'gps', + label: _selectorMetricLabel( + _resolvedMetricLabel('gps', 'GPS', labelOverrides: labelOverrides), + _sourceChannelForField(extraSensorData, 'gps'), + ), + defaultLabel: 'GPS', + channel: _sourceChannelForField(extraSensorData, 'gps'), + valuePreview: + '${gpsLocation.latitude.toStringAsFixed(5)}, ${gpsLocation.longitude.toStringAsFixed(5)}', + previewCardData: SensorMetricCardData( + fieldKey: 'gps', + icon: Icons.place, + label: _resolvedMetricLabel( + 'gps', + 'GPS', + labelOverrides: labelOverrides, + ), + value: + '${gpsLocation.latitude.toStringAsFixed(5)}, ${gpsLocation.longitude.toStringAsFixed(5)}', + secondaryValue: formatPlusCode( + gpsLocation.latitude, + gpsLocation.longitude, + ), + accent: const Color(0xFFAA3F57), + wide: true, + channel: _sourceChannelForField(extraSensorData, 'gps'), + ), + ), + ]; + + // Keys that are already shown as core telemetry fields above. + // Skip them from extraSensorData to avoid duplicates. + final coreFieldKeys = { + if (batteryMilliVolts != null || batteryPercentage != null) + ...extraSensorData?.keys.where( + (k) => + _isSourceChannelMetric(extraSensorData, 'voltage', k) || + _isSourceChannelMetric(extraSensorData, 'battery', k) || + k.startsWith('analog_input_'), + ) ?? + [], + if (temperature != null) + ...extraSensorData?.keys.where( + (k) => _isSourceChannelMetric(extraSensorData, 'temperature', k), + ) ?? + [], + if (humidity != null) + ...extraSensorData?.keys.where( + (k) => _isSourceChannelMetric(extraSensorData, 'humidity', k), + ) ?? + [], + if (pressure != null) + ...extraSensorData?.keys.where( + (k) => _isSourceChannelMetric(extraSensorData, 'pressure', k), + ) ?? + [], + }; + + if (extraSensorData != null) { + for (final key in extraSensorData.keys) { + if (_isTelemetryMetadataKey(key) || coreFieldKeys.contains(key)) { + continue; + } + final metricKey = _parseMetricKey(key); + final fieldKey = _extraFieldKey(key); + final defaultLabel = _formatExtraFieldLabel(key); + final resolvedLabel = _resolvedMetricLabel( + fieldKey, + defaultLabel, + labelOverrides: labelOverrides, + ); + options.add( + SensorMetricOption( + key: fieldKey, + label: _selectorMetricLabel(resolvedLabel, metricKey.channel), + defaultLabel: defaultLabel, + channel: metricKey.channel, + valuePreview: _sensorMetricPreviewValue(key, extraSensorData[key]), + previewCardData: _buildOptionPreviewCardData( + key, + extraSensorData[key], + fieldKey: fieldKey, + label: resolvedLabel, + ), + ), + ); + } + } + + return options; +} + +SensorMetricCardData? _buildOptionPreviewCardData( + String rawKey, + dynamic value, { + required String fieldKey, + required String label, +}) { + final metricKey = _parseMetricKey(rawKey); + final previewValue = _sensorMetricPreviewValue(rawKey, value); + if (previewValue == null) { + return null; + } + + if (_isBinaryMetricBaseKey(metricKey.baseKey)) { + return SensorMetricCardData( + fieldKey: fieldKey, + icon: previewValue == 'On' ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: previewValue, + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + } + + switch (metricKey.baseKey) { + case 'altitude': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.terrain_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF7A5C3E), + channel: metricKey.channel, + ); + case 'illuminance': + final lux = _previewAsDouble(value); + final irradiance = lux == null + ? null + : _previewFormatDaylightIrradiance(lux); + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.light_mode_outlined, + label: label, + value: irradiance ?? previewValue, + accent: const Color(0xFFC17B1D), + channel: metricKey.channel, + ); + case 'presence': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.sensor_occupied_outlined, + label: label, + value: previewValue, + accent: const Color(0xFFAA3F57), + channel: metricKey.channel, + ); + case 'digital_input': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.input_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF3A6D8C), + channel: metricKey.channel, + ); + case 'digital_output': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.output_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + case 'analog_input': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.tune, + label: label, + value: previewValue, + accent: const Color(0xFF5A6C84), + channel: metricKey.channel, + ); + case 'analog_output': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.tune, + label: label, + value: previewValue, + accent: const Color(0xFF4B7785), + channel: metricKey.channel, + ); + case 'accelerometer': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.vibration_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF5A4C99), + wide: true, + channel: metricKey.channel, + ); + case 'gyrometer': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.threed_rotation, + label: label, + value: previewValue, + accent: const Color(0xFF6C4F96), + wide: true, + channel: metricKey.channel, + ); + case 'generic_sensor': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.sensors, + label: label, + value: previewValue, + accent: const Color(0xFF3E657C), + channel: metricKey.channel, + ); + case 'button_event': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.touch_app_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); + case 'dimmer': + case 'light_level': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.tune, + label: label, + value: previewValue, + accent: const Color(0xFF5A6C84), + channel: metricKey.channel, + ); + case 'current': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.electric_bolt, + label: label, + value: previewValue, + accent: const Color(0xFF1C7C54), + channel: metricKey.channel, + ); + case 'signed_current': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.electric_bolt, + label: label, + value: previewValue, + accent: const Color(0xFF1C7C54), + channel: metricKey.channel, + ); + case 'frequency': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.graphic_eq, + label: label, + value: previewValue, + accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, + ); + case 'percentage': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.percent, + label: label, + value: previewValue, + accent: const Color(0xFF4B8E2F), + channel: metricKey.channel, + ); + case 'concentration': + case 'co2': + case 'tvoc': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.bubble_chart_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + case 'power': + case 'signed_power': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.flash_on_outlined, + label: label, + value: previewValue, + accent: const Color(0xFFB5622E), + channel: metricKey.channel, + ); + case 'speed': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.air, + label: label, + value: previewValue, + accent: const Color(0xFF2B78A0), + channel: metricKey.channel, + ); + case 'signed_speed': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.air, + label: label, + value: previewValue, + accent: const Color(0xFF2B78A0), + channel: metricKey.channel, + ); + case 'gust': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.air, + label: label, + value: previewValue, + accent: const Color(0xFF1E88A8), + channel: metricKey.channel, + ); + case 'dew': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.device_thermostat, + label: label, + value: previewValue, + accent: const Color(0xFF2F7AA1), + channel: metricKey.channel, + ); + case 'rain': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.grain, + label: label, + value: previewValue, + accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, + ); + case 'distance': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.straighten, + label: label, + value: previewValue, + accent: const Color(0xFF577590), + channel: metricKey.channel, + ); + case 'duration': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.timer_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF577590), + channel: metricKey.channel, + ); + case 'energy': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.battery_charging_full, + label: label, + value: previewValue, + accent: const Color(0xFF9C6644), + channel: metricKey.channel, + ); + case 'volume': + case 'flow_rate': + case 'volume_storage': + case 'water': + case 'gas_volume': + case 'mass': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.inventory_2_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF5F7C8A), + channel: metricKey.channel, + ); + case 'direction': + case 'rotation': + final degrees = _previewAsDouble(value); + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.explore_outlined, + label: label, + value: degrees == null + ? previewValue + : '${_formatPreviewNumber(degrees, maxFractionDigits: 0)}°', + secondaryValue: degrees == null + ? null + : _previewFormatCardinalDirection(degrees), + accent: const Color(0xFF8A5A44), + channel: metricKey.channel, + directionDegrees: degrees, + ); + case 'unixtime': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.schedule, + label: label, + value: previewValue, + accent: const Color(0xFF6B7280), + wide: true, + channel: metricKey.channel, + ); + case 'colour': + final color = _previewAsRgb(value); + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.palette_outlined, + label: label, + value: previewValue, + secondaryValue: color == null + ? null + : 'R ${color.r} • G ${color.g} • B ${color.b}', + accent: color == null + ? const Color(0xFF3E657C) + : Color.fromARGB(255, color.r, color.g, color.b), + wide: true, + channel: metricKey.channel, + ); + case 'switch': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: previewValue == 'On' ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: previewValue, + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + case 'voltage': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.bolt, + label: label, + value: previewValue, + accent: const Color(0xFF0A7D61), + channel: metricKey.channel, + ); + case 'conductivity': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.science_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + case 'acceleration': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.speed_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF5A4C99), + channel: metricKey.channel, + ); + case 'gyro_rate': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.sync, + label: label, + value: previewValue, + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); + case 'pm25': + case 'pm10': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.grain, + label: label, + value: previewValue, + accent: const Color(0xFF7A6C5D), + channel: metricKey.channel, + ); + case 'uv': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.wb_sunny_outlined, + label: label, + value: previewValue, + accent: const Color(0xFFC17B1D), + channel: metricKey.channel, + ); + } + + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.sensors, + label: label, + value: previewValue, + accent: const Color(0xFF3E657C), + wide: value is Map, + channel: metricKey.channel, + ); +} + +Set sensorMetricKeysFor(Contact? contact) { + return sensorMetricOptionsFor(contact).map((option) => option.key).toSet(); +} + +Map sensorDefaultFieldSpans(Iterable fieldKeys) { + final spans = {}; + if (fieldKeys.contains('gps')) { + spans['gps'] = 2; + } + return spans; +} + +Map sensorFullWidthFieldSpans(Iterable fieldKeys) { + return {for (final fieldKey in fieldKeys) fieldKey: 2}; +} + +double _previewApproxDaylightIrradiance(double lux) { + return lux / 120.0; +} + +String _previewFormatDaylightIrradiance(double lux) { + return '~${_formatPreviewNumber(_previewApproxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2'; +} + +String _previewFormatCardinalDirection(double degrees) { + const points = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + final normalized = ((degrees % 360) + 360) % 360; + final index = ((normalized + 22.5) ~/ 45) % points.length; + return points[index]; +} + +String? _sensorMetricPreviewValue(String rawKey, dynamic value) { + final metricKey = _parseMetricKey(rawKey); + + if (_isBinaryMetricBaseKey(metricKey.baseKey)) { + final isOn = _previewAsBool(value); + if (isOn == null) return null; + return isOn ? 'On' : 'Off'; + } + + switch (metricKey.baseKey) { + case 'altitude': + final meters = _previewAsDouble(value); + if (meters == null) return null; + return '${_formatPreviewNumber(meters, maxFractionDigits: 1)} m'; + + case 'illuminance': + final lux = _previewAsDouble(value); + if (lux == null) return null; + return _previewFormatDaylightIrradiance(lux); + + case 'presence': + final isPresent = _previewAsBool(value); + if (isPresent == null) return null; + return isPresent ? 'Detected' : 'Clear'; + + case 'digital_input': + case 'digital_output': + final isHigh = _previewAsBool(value); + if (isHigh == null) return null; + return isHigh ? 'High' : 'Low'; + + case 'analog_input': + case 'analog_output': + case 'generic_sensor': + case 'light_level': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return _formatPreviewNumber(reading, maxFractionDigits: 3); + + case 'button_event': + final eventCode = _previewAsInt(value); + if (eventCode == null) return null; + return _formatButtonEvent(eventCode); + + case 'dimmer': + final reading = _previewAsInt(value); + if (reading == null) return null; + return reading.toString(); + + case 'accelerometer': + final vector = _previewAsVector3(value); + if (vector == null) return null; + return 'X ${_formatPreviewNumber(vector.x)} • ' + 'Y ${_formatPreviewNumber(vector.y)} • ' + 'Z ${_formatPreviewNumber(vector.z)} g'; + + case 'gyrometer': + final vector = _previewAsVector3(value); + if (vector == null) return null; + return 'X ${_formatPreviewNumber(vector.x)} • ' + 'Y ${_formatPreviewNumber(vector.y)} • ' + 'Z ${_formatPreviewNumber(vector.z)} deg/s'; + + case 'current': + final amps = _previewAsDouble(value); + if (amps == null) return null; + return _formatPreviewCurrent(amps); + + case 'signed_current': + final amps = _previewAsDouble(value); + if (amps == null) return null; + return _formatPreviewCurrent(amps); + + case 'frequency': + final hertz = _previewAsDouble(value); + if (hertz == null) return null; + return _formatPreviewFrequency(hertz); + + case 'percentage': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 1)}%'; + + case 'concentration': + case 'co2': + case 'tvoc': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 0)} ppm'; + + case 'power': + final watts = _previewAsDouble(value); + if (watts == null) return null; + return _formatPreviewPower(watts); + + case 'signed_power': + final watts = _previewAsDouble(value); + if (watts == null) return null; + return _formatPreviewPower(watts); + + case 'speed': + final metersPerSecond = _previewAsDouble(value); + if (metersPerSecond == null) return null; + return _formatPreviewSpeed(metersPerSecond); + + case 'signed_speed': + final metersPerSecond = _previewAsDouble(value); + if (metersPerSecond == null) return null; + return _formatPreviewSpeed(metersPerSecond); + + case 'gust': + final metersPerSecond = _previewAsDouble(value); + if (metersPerSecond == null) return null; + return _formatPreviewSpeed(metersPerSecond); + + case 'dew': + final degreesCelsius = _previewAsDouble(value); + if (degreesCelsius == null) return null; + return '${_formatPreviewNumber(degreesCelsius, maxFractionDigits: 1)}°C'; + + case 'rain': + final millimeters = _previewAsDouble(value); + if (millimeters == null) return null; + return '${_formatPreviewNumber(millimeters, maxFractionDigits: 1)} mm'; + + case 'distance': + final meters = _previewAsDouble(value); + if (meters == null) return null; + return _formatPreviewDistance(meters); + + case 'duration': + final seconds = _previewAsDouble(value); + if (seconds == null) return null; + return '${_formatPreviewNumber(seconds, maxFractionDigits: 2)} s'; + + case 'energy': + final kilowattHours = _previewAsDouble(value); + if (kilowattHours == null) return null; + return _formatPreviewEnergy(kilowattHours); + + case 'volume': + case 'flow_rate': + case 'volume_storage': + case 'water': + case 'gas_volume': + final liters = _previewAsDouble(value); + if (liters == null) return null; + return '${_formatPreviewNumber(liters, maxFractionDigits: 3)} L'; + + case 'mass': + final kilograms = _previewAsDouble(value); + if (kilograms == null) return null; + return '${_formatPreviewNumber(kilograms, maxFractionDigits: 3)} kg'; + + case 'direction': + final degrees = _previewAsDouble(value); + if (degrees == null) return null; + return '${_formatPreviewNumber(degrees, maxFractionDigits: 0)} deg'; + + case 'rotation': + final degrees = _previewAsDouble(value); + if (degrees == null) return null; + return '${_formatPreviewNumber(degrees, maxFractionDigits: 1)} deg'; + + case 'unixtime': + final seconds = _previewAsInt(value); + if (seconds == null) return null; + final timestamp = DateTime.fromMillisecondsSinceEpoch( + seconds * 1000, + isUtc: true, + ).toLocal(); + return _formatPreviewTelemetryDateTime(timestamp); + + case 'colour': + final color = _previewAsRgb(value); + if (color == null) return null; + return '#${color.r.toRadixString(16).padLeft(2, '0').toUpperCase()}' + '${color.g.toRadixString(16).padLeft(2, '0').toUpperCase()}' + '${color.b.toRadixString(16).padLeft(2, '0').toUpperCase()}'; + + case 'switch': + final isOn = _previewAsBool(value); + if (isOn == null) return null; + return isOn ? 'On' : 'Off'; + + case 'voltage': + final volts = _previewAsDouble(value); + if (volts == null) return null; + return '${_formatPreviewNumber(volts, maxFractionDigits: 3)} V'; + + case 'conductivity': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return _formatPreviewNumber(reading, maxFractionDigits: 0); + + case 'acceleration': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 3)} m/s2'; + + case 'gyro_rate': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 3)} deg/s'; + + case 'pm25': + case 'pm10': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 1)} ug/m3'; + + case 'uv': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return _formatPreviewNumber(reading, maxFractionDigits: 1); + } + + if (value is num) { + return _formatPreviewNumber(value, maxFractionDigits: 2); + } + + if (value is Map) { + return value.entries + .map((entry) => '${entry.key} ${entry.value}') + .join(' • '); + } + + return value?.toString(); +} + +_Vector3? _previewAsVector3(dynamic value) { + if (value is! Map) return null; + final x = _previewAsDouble(value['x']); + final y = _previewAsDouble(value['y']); + final z = _previewAsDouble(value['z']); + if (x == null || y == null || z == null) return null; + return _Vector3(x: x, y: y, z: z); +} + +_RgbColor? _previewAsRgb(dynamic value) { + if (value is! Map) return null; + final red = _previewAsInt(value['r']); + final green = _previewAsInt(value['g']); + final blue = _previewAsInt(value['b']); + if (red == null || green == null || blue == null) return null; + return _RgbColor(r: red, g: green, b: blue); +} + +double? _previewAsDouble(dynamic value) { + if (value is num) return value.toDouble(); + return null; +} + +int? _previewAsInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.round(); + return null; +} + +bool? _previewAsBool(dynamic value) { + if (value is bool) return value; + if (value is num) return value != 0; + return null; +} + +String _formatPreviewCurrent(double amps) { + final absolute = amps.abs(); + if (absolute < 1.0) { + return '${_formatPreviewNumber(amps * 1000, maxFractionDigits: 1)} mA'; + } + return '${_formatPreviewNumber(amps, maxFractionDigits: 3)} A'; +} + +String _formatPreviewPower(double watts) { + final absolute = watts.abs(); + if (absolute < 1.0) { + return '${_formatPreviewNumber(watts * 1000, maxFractionDigits: 1)} mW'; + } + return '${_formatPreviewNumber(watts, maxFractionDigits: 2)} W'; +} + +String _formatPreviewFrequency(double hertz) { + final absolute = hertz.abs(); + if (absolute >= 1000000) { + return '${_formatPreviewNumber(hertz / 1000000, maxFractionDigits: 2)} MHz'; + } + if (absolute >= 1000) { + return '${_formatPreviewNumber(hertz / 1000, maxFractionDigits: 2)} kHz'; + } + return '${_formatPreviewNumber(hertz, maxFractionDigits: 0)} Hz'; +} + +String _formatPreviewDistance(double meters) { + final absolute = meters.abs(); + if (absolute < 1.0) { + return '${_formatPreviewNumber(meters * 1000, maxFractionDigits: 0)} mm'; + } + if (absolute >= 1000.0) { + return '${_formatPreviewNumber(meters / 1000, maxFractionDigits: 2)} km'; + } + return '${_formatPreviewNumber(meters, maxFractionDigits: 2)} m'; +} + +String _formatPreviewEnergy(double kilowattHours) { + final absolute = kilowattHours.abs(); + if (absolute < 1.0) { + return '${_formatPreviewNumber(kilowattHours * 1000, maxFractionDigits: 1)} Wh'; + } + return '${_formatPreviewNumber(kilowattHours, maxFractionDigits: 3)} kWh'; +} + +String _formatPreviewNumber(num value, {int maxFractionDigits = 2}) { + final absolute = value.abs(); + final digits = absolute >= 100 + ? 0 + : absolute >= 10 + ? math.min(maxFractionDigits, 1) + : maxFractionDigits; + final text = value.toStringAsFixed(digits); + return _trimFractionZeros(text); +} + +String _trimFractionZeros(String text) { + if (!text.contains('.')) { + return text; + } + return text.replaceFirst(RegExp(r'\.?0+$'), ''); +} + +String _formatPreviewTelemetryDateTime(DateTime timestamp) { + final local = timestamp.toLocal(); + final year = local.year.toString().padLeft(4, '0'); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '$year-$month-$day $hour:$minute'; +} + +class SensorTelemetryCard extends StatelessWidget { + final Contact? contact; + final SensorRefreshState state; + final Set visibleFields; + final List? fieldOrder; + final Map fieldSpans; + final Future Function()? onRemove; + final Future Function()? onRefresh; + final Future Function()? onPing; + final VoidCallback? onCustomize; + final Future Function(Contact contact)? onShowMetHistory; + final Future Function(String fieldKey)? onMetricTap; + final Future Function()? onMoveUp; + final Future Function()? onMoveDown; + final EdgeInsetsGeometry margin; + final String emptyMetricsMessage; + final Map labelOverrides; + final bool showActionSheetOnTap; + + const SensorTelemetryCard({ + super.key, + required this.contact, + required this.state, + required this.visibleFields, + this.fieldOrder, + required this.fieldSpans, + this.onRemove, + this.onRefresh, + this.onPing, + this.onCustomize, + this.onShowMetHistory, + this.onMetricTap, + this.onMoveUp, + this.onMoveDown, + this.margin = const EdgeInsets.only(bottom: 16), + this.emptyMetricsMessage = + 'All fields are hidden. Use Visible fields to choose what to show.', + this.labelOverrides = const {}, + this.showActionSheetOnTap = false, + }); + + String _formatSpeed(num metersPerSecond) { + if (_usesImperialSpeedUnits()) { + final milesPerHour = metersPerSecond * 2.2369362920544; + return '${_formatNumber(milesPerHour, maxFractionDigits: 2)} mph'; + } + + final kilometersPerHour = metersPerSecond * 3.6; + return '${_formatNumber(kilometersPerHour, maxFractionDigits: 2)} km/h'; + } + + bool get _showsMenu => + onRefresh != null || + onPing != null || + onCustomize != null || + onRemove != null || + onMoveUp != null || + onMoveDown != null || + (contact != null && + onShowMetHistory != null && + supportsBTHomeMetHistory(contact)); + + Future _handleAction(BuildContext context, String value) async { + if (value == 'refresh' && onRefresh != null) { + await onRefresh!(); + return; + } + if (value == 'ping' && onPing != null) { + await onPing!(); + return; + } + if (value == 'move_up' && onMoveUp != null) { + await onMoveUp!(); + return; + } + if (value == 'move_down' && onMoveDown != null) { + await onMoveDown!(); + return; + } + if (value == 'remove' && onRemove != null) { + await onRemove!(); + return; + } + if (value == 'customize' && onCustomize != null) { + onCustomize!(); + return; + } + if (value == 'met_history' && + contact != null && + onShowMetHistory != null) { + await onShowMetHistory!(contact!); + } + } + + List<_SensorSheetAction> _buildSheetActions(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final actions = <_SensorSheetAction>[ + if (onRefresh != null) + _SensorSheetAction( + icon: Icons.refresh, + label: l10n.refresh, + onTap: () => _handleAction(context, 'refresh'), + ), + if (onPing != null) + _SensorSheetAction( + icon: Icons.network_ping, + label: AppLocalizations.of(context)!.ping, + onTap: () => _handleAction(context, 'ping'), + ), + if (onCustomize != null) + _SensorSheetAction( + icon: Icons.tune, + label: l10n.customizeFields, + onTap: () => _handleAction(context, 'customize'), + ), + if (onMoveUp != null) + _SensorSheetAction( + icon: Icons.arrow_upward, + label: l10n.moveUp, + onTap: () => _handleAction(context, 'move_up'), + ), + if (onMoveDown != null) + _SensorSheetAction( + icon: Icons.arrow_downward, + label: l10n.moveDown, + onTap: () => _handleAction(context, 'move_down'), + ), + if (contact != null && + onShowMetHistory != null && + supportsBTHomeMetHistory(contact)) + _SensorSheetAction( + icon: Icons.show_chart, + label: AppLocalizations.of(context)!.metHistory, + onTap: () => _handleAction(context, 'met_history'), + ), + if (onRemove != null) + _SensorSheetAction( + icon: Icons.delete_outline_rounded, + label: l10n.remove, + destructive: true, + onTap: () => _handleAction(context, 'remove'), + ), + ]; + return actions; + } + + Future _showActionSheet(BuildContext context) async { + if (!_showsMenu) { + return; + } + final actions = _buildSheetActions(context); + if (actions.isEmpty) { + return; + } + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) => _SensorActionSheet( + contact: contact, + actions: actions, + onClose: () => Navigator.pop(sheetContext), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final telemetry = contact?.telemetry; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final metrics = contact == null || telemetry == null + ? const [] + : _sortMetricsByFieldOrder( + _buildMetricCards(l10n, telemetry, contact!), + ); + + final card = Container( + margin: margin, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surfaceContainerLow, + colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), + ], + ), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.045), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + contact?.displayName ?? 'Unavailable node', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (state == SensorRefreshState.timeout) + _InlineAlertBadge(label: l10n.noResponse), + ], + ), + if (telemetry != null) ...[ + const SizedBox(height: 2), + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + '${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (state == SensorRefreshState.refreshing) + _InlineStateMeta( + color: Color(0xFF266AC2), + spinning: true, + tooltip: l10n.refreshing, + ), + if (state == SensorRefreshState.success) + _InlineStateMeta( + color: Color(0xFF218B63), + icon: Icons.check_circle, + tooltip: AppLocalizations.of(context)!.updated, + ), + if (state == SensorRefreshState.unavailable) + _InlineStateMeta( + color: Color(0xFFB13B55), + icon: Icons.error_outline, + tooltip: l10n.unavailable, + ), + ], + ), + ], + ], + ), + ), + if (_showsMenu) + IconButton( + onPressed: () => _showActionSheet(context), + icon: Icon( + Icons.more_vert, + color: colorScheme.onSurfaceVariant, + ), + tooltip: MaterialLocalizations.of(context).showMenuTooltip, + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 12), + if (contact == null) + const Text( + 'This node is no longer available in the contact list.', + ) + else if (telemetry == null) + const Text( + 'No telemetry received yet. Use Refresh from the menu or pull down to fetch it.', + ) + else if (metrics.isEmpty) + Text(emptyMetricsMessage) + else + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final compactWidth = (constraints.maxWidth - spacing) / 2; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: metrics + .map( + (metric) => SensorMetricTile( + data: metric, + width: + (fieldSpans[metric.fieldKey] == 2 || + metric.wide) + ? constraints.maxWidth + : compactWidth, + onTap: onMetricTap == null + ? null + : () async { + await onMetricTap!(metric.fieldKey); + }, + onLongPress: onRefresh == null + ? null + : () async { + await onRefresh!(); + }, + ), + ) + .toList(), + ); + }, + ), + ], + ), + ), + ); + + return card; + } + + List _buildMetricCards( + AppLocalizations l10n, + dynamic telemetry, + Contact contact, + ) { + final items = []; + + if (visibleFields.contains('voltage') && + telemetry.batteryMilliVolts != null) { + items.add( + SensorMetricCardData( + fieldKey: 'voltage', + icon: Icons.bolt, + label: _resolvedMetricLabel( + 'voltage', + l10n.voltage, + labelOverrides: labelOverrides, + ), + value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V', + accent: const Color(0xFF0A7D61), + channel: _sourceChannelForField(telemetry.extraSensorData, 'voltage'), + ), + ); + } + if (visibleFields.contains('battery') && + telemetry.batteryPercentage != null) { + items.add( + SensorMetricCardData( + fieldKey: 'battery', + icon: Icons.battery_5_bar, + label: _resolvedMetricLabel( + 'battery', + l10n.battery, + labelOverrides: labelOverrides, + ), + value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%', + accent: const Color(0xFF4B8E2F), + channel: _sourceChannelForField(telemetry.extraSensorData, 'battery'), + ), + ); + } + if (visibleFields.contains('temperature') && + telemetry.temperature != null) { + items.add( + SensorMetricCardData( + fieldKey: 'temperature', + icon: Icons.thermostat, + label: _resolvedMetricLabel( + 'temperature', + l10n.temperature, + labelOverrides: labelOverrides, + ), + value: '${telemetry.temperature!.toStringAsFixed(1)}°C', + accent: const Color(0xFFC76821), + channel: _sourceChannelForField( + telemetry.extraSensorData, + 'temperature', + ), + ), + ); + } + if (visibleFields.contains('humidity') && telemetry.humidity != null) { + items.add( + SensorMetricCardData( + fieldKey: 'humidity', + icon: Icons.water_drop, + label: _resolvedMetricLabel( + 'humidity', + l10n.humidity, + labelOverrides: labelOverrides, + ), + value: '${telemetry.humidity!.toStringAsFixed(1)}%', + accent: const Color(0xFF246BB2), + channel: _sourceChannelForField( + telemetry.extraSensorData, + 'humidity', + ), + ), + ); + } + if (visibleFields.contains('pressure') && telemetry.pressure != null) { + items.add( + SensorMetricCardData( + fieldKey: 'pressure', + icon: Icons.compress, + label: _resolvedMetricLabel( + 'pressure', + l10n.pressure, + labelOverrides: labelOverrides, + ), + value: '${telemetry.pressure!.toStringAsFixed(1)} hPa', + accent: const Color(0xFF6B4BAE), + channel: _sourceChannelForField( + telemetry.extraSensorData, + 'pressure', + ), + ), + ); + } + if (visibleFields.contains('gps') && telemetry.gpsLocation != null) { + items.add( + SensorMetricCardData( + fieldKey: 'gps', + icon: Icons.place, + label: _resolvedMetricLabel( + 'gps', + l10n.gpsTelemetry, + labelOverrides: labelOverrides, + ), + value: + '${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}', + accent: const Color(0xFFAA3F57), + wide: true, + mapLocation: LatLng( + telemetry.gpsLocation!.latitude, + telemetry.gpsLocation!.longitude, + ), + secondaryValue: formatPlusCode( + telemetry.gpsLocation!.latitude, + telemetry.gpsLocation!.longitude, + ), + channel: _sourceChannelForField(telemetry.extraSensorData, 'gps'), + ), + ); + } + if (telemetry.extraSensorData != null) { + for (final entry in telemetry.extraSensorData!.entries) { + if (_isTelemetryMetadataKey(entry.key)) { + continue; + } + final fieldKey = _extraFieldKey(entry.key); + if (!visibleFields.contains(fieldKey)) { + continue; + } + final metric = _buildExtraMetricCardData(entry.key, entry.value); + if (metric != null) { + items.add(metric); + } + } + } + + return items; + } + + List _sortMetricsByFieldOrder( + List metrics, + ) { + final order = fieldOrder; + if (order == null || order.isEmpty || metrics.length < 2) { + return metrics; + } + + final orderIndex = { + for (var i = 0; i < order.length; i++) order[i]: i, + }; + final indexedMetrics = metrics.asMap().entries.toList(); + indexedMetrics.sort((left, right) { + final leftOrder = orderIndex[left.value.fieldKey] ?? order.length; + final rightOrder = orderIndex[right.value.fieldKey] ?? order.length; + if (leftOrder != rightOrder) { + return leftOrder.compareTo(rightOrder); + } + return left.key.compareTo(right.key); + }); + return indexedMetrics.map((entry) => entry.value).toList(growable: false); + } + + SensorMetricCardData? _buildExtraMetricCardData( + String rawKey, + dynamic value, + ) { + final metricKey = _parseMetricKey(rawKey); + final fieldKey = _extraFieldKey(rawKey); + final label = _resolvedMetricLabel( + fieldKey, + _formatExtraFieldLabel(rawKey), + labelOverrides: labelOverrides, + ); + + if (_isBinaryMetricBaseKey(metricKey.baseKey)) { + final isOn = _asBool(value); + if (isOn == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: isOn ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: isOn ? 'On' : 'Off', + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + } + + switch (metricKey.baseKey) { + case 'altitude': + final meters = _asDouble(value); + if (meters == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.terrain_outlined, + label: label, + value: '${_formatNumber(meters, maxFractionDigits: 1)} m', + accent: const Color(0xFF7A5C3E), + channel: metricKey.channel, + ); + + case 'illuminance': + final lux = _asDouble(value); + if (lux == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.light_mode_outlined, + label: label, + value: _formatDaylightIrradiance(lux), + accent: const Color(0xFFC17B1D), + channel: metricKey.channel, + ); + + case 'presence': + final isPresent = _asBool(value); + if (isPresent == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensor_occupied_outlined, + label: label, + value: isPresent ? 'Detected' : 'Clear', + accent: const Color(0xFFAA3F57), + channel: metricKey.channel, + ); + + case 'digital_input': + final isHigh = _asBool(value); + if (isHigh == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.input_outlined, + label: label, + value: isHigh ? 'High' : 'Low', + accent: const Color(0xFF3A6D8C), + channel: metricKey.channel, + ); + + case 'digital_output': + final isHigh = _asBool(value); + if (isHigh == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.output_outlined, + label: label, + value: isHigh ? 'High' : 'Low', + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + + case 'analog_input': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.tune, + label: label, + value: _formatNumber(reading, maxFractionDigits: 3), + accent: const Color(0xFF5A6C84), + channel: metricKey.channel, + ); + + case 'analog_output': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.tune, + label: label, + value: _formatNumber(reading, maxFractionDigits: 3), + accent: const Color(0xFF4B7785), + channel: metricKey.channel, + ); + + case 'accelerometer': + final vector = _asVector3(value); + if (vector == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.vibration_outlined, + label: label, + value: + 'X ${_formatNumber(vector.x)} • Y ${_formatNumber(vector.y)} • Z ${_formatNumber(vector.z)} g', + secondaryValue: + '|a| ${_formatNumber(_vectorMagnitude(vector), maxFractionDigits: 2)} g', + accent: const Color(0xFF5A4C99), + wide: true, + channel: metricKey.channel, + ); + + case 'gyrometer': + final vector = _asVector3(value); + if (vector == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.threed_rotation, + label: label, + value: + 'X ${_formatNumber(vector.x)} • Y ${_formatNumber(vector.y)} • Z ${_formatNumber(vector.z)} deg/s', + secondaryValue: + '|w| ${_formatNumber(_vectorMagnitude(vector), maxFractionDigits: 2)} deg/s', + accent: const Color(0xFF6C4F96), + wide: true, + channel: metricKey.channel, + ); + + case 'generic_sensor': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensors, + label: label, + value: _formatNumber(reading, maxFractionDigits: 2), + accent: const Color(0xFF3E657C), + channel: metricKey.channel, + ); + + case 'button_event': + final reading = _asInt(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.touch_app_outlined, + label: label, + value: _formatButtonEvent(reading), + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); + + case 'dimmer': + case 'light_level': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.tune, + label: label, + value: _formatNumber(reading, maxFractionDigits: 2), + accent: const Color(0xFF5A6C84), + channel: metricKey.channel, + ); + + case 'current': + final amps = _asDouble(value); + if (amps == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.electric_bolt, + label: label, + value: _formatCurrent(amps), + accent: const Color(0xFF1C7C54), + channel: metricKey.channel, + ); + + case 'signed_current': + final amps = _asDouble(value); + if (amps == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.electric_bolt, + label: label, + value: _formatCurrent(amps), + accent: const Color(0xFF1C7C54), + channel: metricKey.channel, + ); + + case 'frequency': + final hz = _asDouble(value); + if (hz == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.graphic_eq, + label: label, + value: _formatFrequency(hz), + accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, + ); + + case 'percentage': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.percent, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 1)}%', + accent: const Color(0xFF4B8E2F), + channel: metricKey.channel, + ); + + case 'concentration': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.bubble_chart_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 0)} ppm', + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + + case 'power': + final watts = _asDouble(value); + if (watts == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.flash_on_outlined, + label: label, + value: _formatPower(watts), + accent: const Color(0xFFB5622E), + channel: metricKey.channel, + ); + + case 'signed_power': + final watts = _asDouble(value); + if (watts == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.flash_on_outlined, + label: label, + value: _formatPower(watts), + accent: const Color(0xFFB5622E), + channel: metricKey.channel, + ); + + case 'speed': + final metersPerSecond = _asDouble(value); + if (metersPerSecond == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.air, + label: label, + value: _formatSpeed(metersPerSecond), + accent: const Color(0xFF2B78A0), + channel: metricKey.channel, + ); + + case 'signed_speed': + final metersPerSecond = _asDouble(value); + if (metersPerSecond == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.air, + label: label, + value: _formatSpeed(metersPerSecond), + accent: const Color(0xFF2B78A0), + channel: metricKey.channel, + ); + + case 'gust': + final metersPerSecond = _asDouble(value); + if (metersPerSecond == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.air, + label: label, + value: _formatSpeed(metersPerSecond), + accent: const Color(0xFF1E88A8), + channel: metricKey.channel, + ); + + case 'dew': + final degreesCelsius = _asDouble(value); + if (degreesCelsius == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.device_thermostat, + label: label, + value: '${_formatNumber(degreesCelsius, maxFractionDigits: 1)}°C', + accent: const Color(0xFF2F7AA1), + channel: metricKey.channel, + ); + + case 'rain': + final millimeters = _asDouble(value); + if (millimeters == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.grain, + label: label, + value: '${_formatNumber(millimeters, maxFractionDigits: 1)} mm', + accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, + ); + + case 'distance': + final meters = _asDouble(value); + if (meters == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.straighten, + label: label, + value: _formatDistance(meters), + accent: const Color(0xFF577590), + channel: metricKey.channel, + ); + + case 'duration': + final seconds = _asDouble(value); + if (seconds == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.timer_outlined, + label: label, + value: '${_formatNumber(seconds, maxFractionDigits: 2)} s', + accent: const Color(0xFF577590), + channel: metricKey.channel, + ); + + case 'energy': + final kwh = _asDouble(value); + if (kwh == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.battery_charging_full, + label: label, + value: _formatEnergy(kwh), + accent: const Color(0xFF9C6644), + channel: metricKey.channel, + ); + + case 'volume': + case 'flow_rate': + case 'volume_storage': + case 'water': + case 'gas_volume': + case 'mass': + final reading = _asDouble(value); + if (reading == null) return null; + final unit = metricKey.baseKey == 'mass' ? 'kg' : 'L'; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.inventory_2_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 3)} $unit', + accent: const Color(0xFF5F7C8A), + channel: metricKey.channel, + ); + + case 'direction': + final degrees = _asDouble(value); + if (degrees == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.explore_outlined, + label: label, + value: '${_formatNumber(degrees, maxFractionDigits: 0)}°', + secondaryValue: _formatCardinalDirection(degrees), + accent: const Color(0xFF8A5A44), + channel: metricKey.channel, + directionDegrees: degrees, + ); + + case 'rotation': + final degrees = _asDouble(value); + if (degrees == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.explore_outlined, + label: label, + value: '${_formatNumber(degrees, maxFractionDigits: 1)} deg', + accent: const Color(0xFF8A5A44), + channel: metricKey.channel, + ); + + case 'unixtime': + final seconds = _asInt(value); + if (seconds == null) return null; + final timestamp = DateTime.fromMillisecondsSinceEpoch( + seconds * 1000, + isUtc: true, + ).toLocal(); + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.schedule, + label: label, + value: _formatTelemetryDateTime(timestamp), + secondaryValue: _formatTelemetryTime(timestamp), + accent: const Color(0xFF6B7280), + wide: true, + channel: metricKey.channel, + ); + + case 'colour': + final color = _asRgb(value); + if (color == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.palette_outlined, + label: label, + value: + '#${color.r.toRadixString(16).padLeft(2, '0').toUpperCase()}${color.g.toRadixString(16).padLeft(2, '0').toUpperCase()}${color.b.toRadixString(16).padLeft(2, '0').toUpperCase()}', + secondaryValue: 'R ${color.r} • G ${color.g} • B ${color.b}', + accent: Color.fromARGB(255, color.r, color.g, color.b), + wide: true, + channel: metricKey.channel, + ); + + case 'switch': + final isOn = _asBool(value); + if (isOn == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: isOn ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: isOn ? 'On' : 'Off', + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + + case 'voltage': + final volts = _asDouble(value); + if (volts == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.bolt, + label: label, + value: '${_formatNumber(volts, maxFractionDigits: 3)} V', + accent: const Color(0xFF0A7D61), + channel: metricKey.channel, + ); + + case 'conductivity': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.science_outlined, + label: label, + value: _formatNumber(reading, maxFractionDigits: 0), + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + + case 'acceleration': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.speed_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 3)} m/s2', + accent: const Color(0xFF5A4C99), + channel: metricKey.channel, + ); + + case 'gyro_rate': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sync, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 3)} deg/s', + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); + } + + switch (metricKey.baseKey) { + case 'co2': + case 'tvoc': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.bubble_chart_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 0)} ppm', + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + + case 'pm25': + case 'pm10': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.grain, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 1)} ug/m3', + accent: const Color(0xFF7A6C5D), + channel: metricKey.channel, + ); + + case 'uv': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.wb_sunny_outlined, + label: label, + value: _formatNumber(reading, maxFractionDigits: 1), + accent: const Color(0xFFC17B1D), + channel: metricKey.channel, + ); + } + + if (value is num) { + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensors, + label: label, + value: _formatNumber(value, maxFractionDigits: 2), + accent: const Color(0xFF3E657C), + channel: metricKey.channel, + ); + } + + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensors, + label: label, + value: '$value', + accent: const Color(0xFF3E657C), + wide: value is Map, + channel: metricKey.channel, + ); + } + + double _approxDaylightIrradiance(double lux) { + return lux / 120.0; + } + + String _formatDaylightIrradiance(double lux) { + return '~${_formatNumber(_approxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2'; + } + + String _formatCurrent(double amps) { + final absolute = amps.abs(); + if (absolute < 1.0) { + return '${_formatNumber(amps * 1000, maxFractionDigits: 1)} mA'; + } + return '${_formatNumber(amps, maxFractionDigits: 3)} A'; + } + + String _formatPower(double watts) { + final absolute = watts.abs(); + if (absolute < 1.0) { + return '${_formatNumber(watts * 1000, maxFractionDigits: 1)} mW'; + } + return '${_formatNumber(watts, maxFractionDigits: 2)} W'; + } + + String _formatFrequency(double hertz) { + final absolute = hertz.abs(); + if (absolute >= 1000000) { + return '${_formatNumber(hertz / 1000000, maxFractionDigits: 2)} MHz'; + } + if (absolute >= 1000) { + return '${_formatNumber(hertz / 1000, maxFractionDigits: 2)} kHz'; + } + return '${_formatNumber(hertz, maxFractionDigits: 0)} Hz'; + } + + String _formatDistance(double meters) { + final absolute = meters.abs(); + if (absolute < 1.0) { + return '${_formatNumber(meters * 1000, maxFractionDigits: 0)} mm'; + } + if (absolute >= 1000.0) { + return '${_formatNumber(meters / 1000, maxFractionDigits: 2)} km'; + } + return '${_formatNumber(meters, maxFractionDigits: 2)} m'; + } + + String _formatEnergy(double kilowattHours) { + final absolute = kilowattHours.abs(); + if (absolute < 1.0) { + return '${_formatNumber(kilowattHours * 1000, maxFractionDigits: 1)} Wh'; + } + return '${_formatNumber(kilowattHours, maxFractionDigits: 3)} kWh'; + } + + String _formatCardinalDirection(double degrees) { + const points = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + final normalized = ((degrees % 360) + 360) % 360; + final index = ((normalized + 22.5) ~/ 45) % points.length; + return points[index]; + } + + String _formatNumber(num value, {int maxFractionDigits = 2}) { + final absolute = value.abs(); + final digits = absolute >= 100 + ? 0 + : absolute >= 10 + ? math.min(maxFractionDigits, 1) + : maxFractionDigits; + final text = value.toStringAsFixed(digits); + return _trimFractionZeros(text); + } + + _Vector3? _asVector3(dynamic value) { + if (value is! Map) return null; + final x = _asDouble(value['x']); + final y = _asDouble(value['y']); + final z = _asDouble(value['z']); + if (x == null || y == null || z == null) return null; + return _Vector3(x: x, y: y, z: z); + } + + _RgbColor? _asRgb(dynamic value) { + if (value is! Map) return null; + final red = _asInt(value['r']); + final green = _asInt(value['g']); + final blue = _asInt(value['b']); + if (red == null || green == null || blue == null) return null; + return _RgbColor(r: red, g: green, b: blue); + } + + double? _asDouble(dynamic value) { + if (value is num) return value.toDouble(); + return null; + } + + int? _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.round(); + return null; + } + + bool? _asBool(dynamic value) { + if (value is bool) return value; + if (value is num) return value != 0; + return null; + } + + double _vectorMagnitude(_Vector3 vector) { + return math.sqrt( + vector.x * vector.x + vector.y * vector.y + vector.z * vector.z, + ); + } + + String _formatTelemetryTime(DateTime timestamp) { + final diff = DateTime.now().difference(timestamp); + if (diff.inMinutes < 1) return 'now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } + + String _formatTelemetryDateTime(DateTime timestamp) { + final local = timestamp.toLocal(); + final year = local.year.toString().padLeft(4, '0'); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '$year-$month-$day $hour:$minute'; + } +} + +class _InlineStateMeta extends StatelessWidget { + final Color color; + final IconData? icon; + final bool spinning; + final String? tooltip; + + const _InlineStateMeta({ + required this.color, + this.icon, + this.spinning = false, + this.tooltip, + }); + + @override + Widget build(BuildContext context) { + final badge = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + ), + child: spinning + ? SizedBox( + width: 11, + height: 11, + child: CircularProgressIndicator( + strokeWidth: 1.7, + valueColor: AlwaysStoppedAnimation(color), + ), + ) + : Icon( + icon ?? Icons.info_outline, + size: 11, + color: color, + ), + ); + + if (tooltip == null || tooltip!.isEmpty) { + return badge; + } + + return Tooltip( + message: tooltip!, + child: badge, + ); + } +} + +class _InlineAlertBadge extends StatelessWidget { + final String label; + + const _InlineAlertBadge({required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFC17B1D).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: const Color(0xFFC17B1D), + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class SensorMetricTile extends StatefulWidget { + final SensorMetricCardData data; + final double width; + final String keyPrefix; + final bool allowMapPreview; + final GestureTapCallback? onTap; + final GestureLongPressCallback? onLongPress; + + const SensorMetricTile({ + super.key, + required this.data, + required this.width, + this.keyPrefix = 'sensor_metric', + this.allowMapPreview = true, + this.onTap, + this.onLongPress, + }); + + @override + State createState() => _SensorMetricTileState(); +} + +class _SensorMetricTileState extends State { + final flutter_map.MapController _previewMapController = + flutter_map.MapController(); + + @override + void didUpdateWidget(covariant SensorMetricTile oldWidget) { + super.didUpdateWidget(oldWidget); + final previousLocation = oldWidget.data.mapLocation; + final nextLocation = widget.data.mapLocation; + if (!_sameMapLocation(previousLocation, nextLocation) && + nextLocation != null && + widget.allowMapPreview) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _previewMapController.move( + nextLocation, + _previewMapController.camera.zoom, + ); + }); + } + } + + bool _sameMapLocation(LatLng? a, LatLng? b) { + if (a == null || b == null) { + return a == b; + } + return a.latitude == b.latitude && a.longitude == b.longitude; + } + + Future _showExpandedMap(BuildContext context) async { + final location = widget.data.mapLocation; + if (location == null) return; + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (pageContext) { + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.data.label), + Text( + widget.data.value, + style: Theme.of(pageContext).textTheme.bodySmall, + ), + ], + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.data.secondaryValue != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + widget.data.secondaryValue!, + style: Theme.of(pageContext).textTheme.bodyMedium, + ), + ), + Expanded( + child: flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCenter: location, + initialZoom: 15, + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.meshcore.sar.meshcore_sar_app', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: location, + width: 40, + height: 40, + child: Icon( + Icons.location_on, + color: widget.data.accent, + size: 34, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + }, + fullscreenDialog: true, + ), + ); + } + + @override + Widget build(BuildContext context) { + final data = widget.data; + return Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey('${widget.keyPrefix}_${data.fieldKey}'), + borderRadius: BorderRadius.circular(22), + onTap: widget.onTap, + onLongPress: widget.onLongPress, + child: Container( + width: widget.width, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: data.accent.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: data.accent.withValues(alpha: 0.14)), + ), + child: data.directionDegrees != null + ? Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _DirectionDial( + accent: data.accent, + degrees: data.directionDegrees!, + cardinal: data.secondaryValue ?? '', + compact: true, + ), + const SizedBox(width: 10), + Expanded( + child: _MetricText( + data: data, + keyPrefix: widget.keyPrefix, + ), + ), + ], + ), + if (data.channel != null) + Positioned( + right: 0, + bottom: 0, + child: Text( + 'ch${data.channel}', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: data.accent.withValues(alpha: 0.5), + ), + ), + ), + ], + ) + : data.mapLocation == null || !widget.allowMapPreview + ? Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetricIcon(accent: data.accent, icon: data.icon), + const SizedBox(width: 10), + Expanded( + child: _MetricText( + data: data, + keyPrefix: widget.keyPrefix, + ), + ), + ], + ), + if (data.channel != null) + Positioned( + right: 0, + bottom: 0, + child: Text( + 'ch${data.channel}', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: data.accent.withValues(alpha: 0.5), + ), + ), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetricIcon(accent: data.accent, icon: data.icon), + const SizedBox(width: 10), + Expanded( + child: _MetricText( + data: data, + keyPrefix: widget.keyPrefix, + ), + ), + ], + ), + const SizedBox(height: 10), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => _showExpandedMap(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: SizedBox( + height: 104, + width: double.infinity, + child: Stack( + children: [ + flutter_map.FlutterMap( + mapController: _previewMapController, + options: flutter_map.MapOptions( + initialCenter: data.mapLocation!, + initialZoom: 14, + interactionOptions: + const flutter_map.InteractionOptions( + flags: + flutter_map.InteractiveFlag.none, + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.meshcore.sar.meshcore_sar_app', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: data.mapLocation!, + width: 32, + height: 32, + child: Icon( + Icons.location_on, + color: data.accent, + size: 28, + ), + ), + ], + ), + ], + ), + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.55, + ), + borderRadius: BorderRadius.circular(999), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.open_in_full, + size: 12, + color: Colors.white, + ), + SizedBox(width: 4), + Text( + 'Open map', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _MetricIcon extends StatelessWidget { + final Color accent; + final IconData icon; + + const _MetricIcon({required this.accent, required this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: accent, size: 18), + ); + } +} + +class _MetricText extends StatelessWidget { + final SensorMetricCardData data; + final String keyPrefix; + + const _MetricText({required this.data, required this.keyPrefix}); + + @override + Widget build(BuildContext context) { + if (data.directionDegrees != null) { + return _DirectionMetricText(data: data); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: data.accent, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + data.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + if (data.secondaryValue != null) ...[ + const SizedBox(height: 4), + Text( + data.secondaryValue!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ); + } +} + +class _DirectionMetricText extends StatelessWidget { + final SensorMetricCardData data; + + const _DirectionMetricText({required this.data}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final directionLabel = data.secondaryValue ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + data.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: data.accent, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + data.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + if (directionLabel.isNotEmpty) + Text( + directionLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + color: data.accent.withValues(alpha: 0.9), + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + ], + ), + ], + ); + } +} + +class _DirectionDial extends StatelessWidget { + final Color accent; + final double degrees; + final String cardinal; + final bool compact; + + const _DirectionDial({ + required this.accent, + required this.degrees, + required this.cardinal, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + final normalized = ((degrees % 360) + 360) % 360; + final size = compact ? 34.0 : 44.0; + final innerSize = compact ? 24.0 : 30.0; + final arrowSize = compact ? 14.0 : 16.0; + final northTop = compact ? 1.0 : 3.0; + + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: size, + height: size, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.08), + shape: BoxShape.circle, + border: Border.all(color: accent.withValues(alpha: 0.2)), + ), + ), + Container( + width: innerSize, + height: innerSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: accent.withValues(alpha: 0.2)), + ), + ), + Transform.rotate( + angle: normalized * math.pi / 180, + child: Icon( + Icons.navigation_rounded, + size: arrowSize, + color: accent, + ), + ), + Positioned( + top: northTop, + child: Text( + 'N', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: accent.withValues(alpha: 0.65), + fontWeight: FontWeight.w800, + fontSize: compact ? 7.5 : 9, + ), + ), + ), + ], + ), + ); + } +} + +class SensorMetricCardData { + final String fieldKey; + final IconData icon; + final String label; + final String value; + final String? secondaryValue; + final double? directionDegrees; + final Color accent; + final bool wide; + final LatLng? mapLocation; + final int? channel; + + const SensorMetricCardData({ + required this.fieldKey, + required this.icon, + required this.label, + required this.value, + this.secondaryValue, + this.directionDegrees, + required this.accent, + this.wide = false, + this.mapLocation, + this.channel, + }); +} + +class _SensorSheetAction { + final IconData icon; + final String label; + final Future Function() onTap; + final bool destructive; + + const _SensorSheetAction({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + }); +} + +class _SensorActionSheet extends StatelessWidget { + final Contact? contact; + final List<_SensorSheetAction> actions; + final VoidCallback onClose; + + const _SensorActionSheet({ + required this.contact, + required this.actions, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final bottomInset = MediaQuery.of(context).viewPadding.bottom; + final title = contact?.displayName ?? 'Sensor'; + final subtitle = contact == null ? 'Unavailable node' : contact!.publicKeyShort; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.82, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.12), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], + ), + child: Material( + color: colorScheme.surface, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: LinearGradient( + colors: [ + colorScheme.primaryContainer, + colorScheme.secondaryContainer, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Icon( + Icons.sensors_rounded, + color: colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.45, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: contact == null ? null : 'monospace', + ), + ), + ], + ), + ), + ), + IconButton( + onPressed: onClose, + icon: const Icon(Icons.close), + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + ), + ], + ), + const SizedBox(height: 18), + ...actions.map( + (action) => _SensorActionTile( + action: action, + onClose: onClose, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SensorActionTile extends StatelessWidget { + final _SensorSheetAction action; + final VoidCallback onClose; + + const _SensorActionTile({ + required this.action, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final foreground = action.destructive + ? colorScheme.error + : colorScheme.onSurface; + final iconBackground = action.destructive + ? colorScheme.error.withValues(alpha: 0.12) + : colorScheme.surfaceContainerHigh; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: InkWell( + borderRadius: BorderRadius.circular(22), + onTap: () async { + onClose(); + await action.onTap(); + }, + child: Ink( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.45), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconBackground, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(action.icon, color: foreground), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + action.label, + style: theme.textTheme.titleMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + Icon( + Icons.chevron_right_rounded, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} + +class _ParsedMetricKey { + final String baseKey; + final int? channel; + + const _ParsedMetricKey({required this.baseKey, this.channel}); +} + +class _Vector3 { + final double x; + final double y; + final double z; + + const _Vector3({required this.x, required this.y, required this.z}); +} + +class _RgbColor { + final int r; + final int g; + final int b; + + const _RgbColor({required this.r, required this.g, required this.b}); +} + +const String _telemetrySourceChannelPrefix = '__source_channel:'; +const String _rawTelemetryHexKey = '__raw_lpp_hex'; + +String _extraFieldKey(String label) { + return 'extra:$label'; +} + +bool _isTelemetryMetadataKey(String key) { + return key.startsWith(_telemetrySourceChannelPrefix) || + key == _rawTelemetryHexKey; +} + +String _telemetrySourceChannelKey(String fieldKey) { + return '$_telemetrySourceChannelPrefix$fieldKey'; +} + +int? _sourceChannelForField( + Map? extraSensorData, + String fieldKey, +) { + final value = extraSensorData?[_telemetrySourceChannelKey(fieldKey)]; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return null; +} + +bool _isSourceChannelMetric( + Map? extraSensorData, + String fieldKey, + String metricKey, +) { + final sourceChannel = _sourceChannelForField(extraSensorData, fieldKey); + if (sourceChannel == null) { + return false; + } + return metricKey == '${fieldKey}_$sourceChannel'; +} + +String _resolvedMetricLabel( + String fieldKey, + String defaultLabel, { + Map labelOverrides = const {}, +}) { + final override = labelOverrides[fieldKey]?.trim(); + if (override == null || override.isEmpty) { + return defaultLabel; + } + return override; +} + +String _selectorMetricLabel(String label, int? channel) { + if (channel == null) { + return label; + } + return '$label (ch $channel)'; +} + +bool _isBinaryMetricBaseKey(String baseKey) { + return baseKey.startsWith('binary_'); +} + +String _formatButtonEvent(int value) { + switch (value) { + case 0: + return 'None'; + case 1: + return 'Press'; + case 2: + return 'Double press'; + case 3: + return 'Triple press'; + case 4: + return 'Long press'; + case 5: + return 'Long double'; + case 6: + return 'Long triple'; + case 128: + return 'Hold'; + } + return value.toString(); +} + +String _formatExtraFieldLabel(String rawKey) { + final metricKey = _parseMetricKey(rawKey); + return _knownMetricLabels[metricKey.baseKey] ?? + _fallbackMetricLabel(metricKey.baseKey); +} + +const List _knownMetricBaseKeys = [ + 'generic_sensor', + 'digital_output', + 'digital_input', + 'analog_output', + 'analog_input', + 'accelerometer', + 'illuminance', + 'concentration', + 'percentage', + 'direction', + 'frequency', + 'distance', + 'altitude', + 'humidity', + 'pressure', + 'temperature', + 'gyrometer', + 'unixtime', + 'presence', + 'current', + 'voltage', + 'colour', + 'switch', + 'energy', + 'power', + 'speed', + 'gust', + 'dew', + 'rain', + 'pm25', + 'pm10', + 'tvoc', + 'co2', + 'rpm', + 'conductivity', + 'uv', + 'button_event', + 'dimmer', + 'light_level', + 'rotation', + 'duration', + 'acceleration', + 'gyro_rate', + 'volume', + 'flow_rate', + 'volume_storage', + 'water', + 'gas_volume', + 'mass', + 'signed_speed', + 'signed_power', + 'signed_current', + 'binary_bool', + 'binary_power_switch', + 'binary_open', + 'binary_battery_low', + 'binary_charging', + 'binary_carbon_monoxide', + 'binary_cold', + 'binary_connectivity', + 'binary_door', + 'binary_garage_door', + 'binary_gas', + 'binary_heat', + 'binary_light', + 'binary_lock', + 'binary_moisture', + 'binary_motion', + 'binary_moving', + 'binary_occupancy', + 'binary_plug', + 'binary_presence', + 'binary_problem', + 'binary_running', + 'binary_safety', + 'binary_smoke', + 'binary_sound', + 'binary_tamper', + 'binary_vibration', + 'binary_window', +]; + +const Map _knownMetricLabels = { + 'accelerometer': 'Accelerometer', + 'altitude': 'Altitude', + 'analog_input': 'Analog input', + 'analog_output': 'Analog output', + 'co2': 'CO2', + 'colour': 'Color', + 'concentration': 'Concentration', + 'conductivity': 'Conductivity', + 'current': 'Current', + 'button_event': 'Button', + 'dimmer': 'Dimmer', + 'digital_input': 'Digital input', + 'digital_output': 'Digital output', + 'direction': 'Direction', + 'distance': 'Distance', + 'duration': 'Duration', + 'energy': 'Energy', + 'frequency': 'Frequency', + 'flow_rate': 'Flow rate', + 'gas_volume': 'Gas volume', + 'generic_sensor': 'Generic sensor', + 'gyro_rate': 'Gyro rate', + 'gyrometer': 'Gyrometer', + 'humidity': 'Humidity', + 'illuminance': 'Illuminance', + 'light_level': 'Light level', + 'mass': 'Mass', + 'percentage': 'Percentage', + 'pm10': 'PM10', + 'pm25': 'PM2.5', + 'power': 'Power', + 'presence': 'Presence', + 'pressure': 'Pressure', + 'rain': 'Rain', + 'rotation': 'Rotation', + 'rpm': 'RPM', + 'gust': 'Wind gust', + 'signed_current': 'Signed current', + 'signed_power': 'Signed power', + 'signed_speed': 'Signed speed', + 'speed': 'Speed', + 'switch': 'Switch', + 'dew': 'Dew point', + 'temperature': 'Temperature', + 'tvoc': 'TVOC', + 'unixtime': 'Time', + 'uv': 'UV index', + 'volume': 'Volume', + 'volume_storage': 'Storage volume', + 'voltage': 'Voltage', + 'water': 'Water', + 'binary_battery_low': 'Battery low', + 'binary_bool': 'Binary', + 'binary_carbon_monoxide': 'Carbon monoxide', + 'binary_charging': 'Charging', + 'binary_cold': 'Cold', + 'binary_connectivity': 'Connectivity', + 'binary_door': 'Door', + 'binary_garage_door': 'Garage door', + 'binary_gas': 'Gas', + 'binary_heat': 'Heat', + 'binary_light': 'Light', + 'binary_lock': 'Lock', + 'binary_moisture': 'Moisture', + 'binary_motion': 'Motion', + 'binary_moving': 'Moving', + 'binary_occupancy': 'Occupancy', + 'binary_open': 'Open', + 'binary_plug': 'Plug', + 'binary_power_switch': 'Power switch', + 'binary_presence': 'Presence', + 'binary_problem': 'Problem', + 'binary_running': 'Running', + 'binary_safety': 'Safety', + 'binary_smoke': 'Smoke', + 'binary_sound': 'Sound', + 'binary_tamper': 'Tamper', + 'binary_vibration': 'Vibration', + 'binary_window': 'Window', +}; + +_ParsedMetricKey _parseMetricKey(String rawKey) { + for (final baseKey in _knownMetricBaseKeys) { + if (rawKey == baseKey) { + return _ParsedMetricKey(baseKey: baseKey); + } + if (rawKey.startsWith('${baseKey}_')) { + final channel = int.tryParse(rawKey.substring(baseKey.length + 1)); + if (channel != null) { + return _ParsedMetricKey(baseKey: baseKey, channel: channel); + } + } + } + + final parts = rawKey.split('_'); + if (parts.length > 1) { + final channel = int.tryParse(parts.last); + if (channel != null) { + return _ParsedMetricKey( + baseKey: parts.sublist(0, parts.length - 1).join('_'), + channel: channel, + ); + } + } + + return _ParsedMetricKey(baseKey: rawKey); +} + +String _fallbackMetricLabel(String rawKey) { + return rawKey + .split('_') + .where((part) => part.isNotEmpty) + .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') + .join(' '); +} diff --git a/lib/widgets/settings/traffic_stats_reporting_section.dart b/lib/widgets/settings/traffic_stats_reporting_section.dart new file mode 100644 index 0000000..bc9ddb2 --- /dev/null +++ b/lib/widgets/settings/traffic_stats_reporting_section.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../services/traffic_stats_reporting_service.dart'; + +class TrafficStatsReportingSection extends StatelessWidget { + final TrafficStatsReportingService service; + + const TrafficStatsReportingSection({super.key, required this.service}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + dense: true, + secondary: const Icon(Icons.cloud_upload_outlined, size: 20), + title: Text(AppLocalizations.of(context)!.anonymousRxStats), + subtitle: const Text( + 'Upload packet totals every 5 min', + ), + value: service.isEnabled, + onChanged: (value) async { + await service.setEnabled(value); + }, + ), + if (service.isEnabled) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Row( + children: [ + Expanded( + child: Text( + _statusText(service), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + TextButton.icon( + onPressed: _openStatsDashboard, + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(AppLocalizations.of(context)!.view), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + textStyle: theme.textTheme.labelSmall, + ), + ), + ], + ), + ), + ], + ); + } + + static Future _openStatsDashboard() async { + final url = TrafficStatsReportingService.dashboardUri; + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + } + + static String _statusText(TrafficStatsReportingService service) { + final parts = []; + parts.add('Pending: ${service.pendingUploadCount}'); + if (service.lastSuccessAt != null) { + parts.add('Sent: ${_formatDateTime(service.lastSuccessAt!.toLocal())}'); + } + if (service.lastError != null && service.lastError!.isNotEmpty) { + parts.add('Error: ${service.lastError}'); + } + return parts.join(' · '); + } + + static String _formatDateTime(DateTime value) { + final month = value.month.toString().padLeft(2, '0'); + final day = value.day.toString().padLeft(2, '0'); + final hour = value.hour.toString().padLeft(2, '0'); + final minute = value.minute.toString().padLeft(2, '0'); + return '${value.year}-$month-$day $hour:$minute'; + } +} diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart new file mode 100644 index 0000000..8c91b0f --- /dev/null +++ b/lib/widgets/update_dialog.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../models/update_info.dart'; +import '../l10n/app_localizations.dart'; + +/// Dialog widget that displays when a new app version is available +/// Shows current vs latest commit hash and provides download button +class UpdateDialog extends StatelessWidget { + final UpdateInfo updateInfo; + + const UpdateDialog({ + super.key, + required this.updateInfo, + }); + + /// Show the update dialog + static Future show(BuildContext context, UpdateInfo updateInfo) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (context) => UpdateDialog(updateInfo: updateInfo), + ); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + + return AlertDialog( + icon: const Icon( + Icons.system_update, + size: 48, + color: Colors.blue, + ), + title: Text(loc.updateAvailable), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Current version + _buildInfoRow( + context, + label: loc.currentVersion, + value: updateInfo.currentCommitHash, + ), + const SizedBox(height: 12), + + // Latest version + _buildInfoRow( + context, + label: loc.latestVersion, + value: updateInfo.latestCommitHash ?? 'unknown', + ), + + // Optional: Build timestamp + if (updateInfo.timestamp != null) ...[ + SizedBox(height: 12), + _buildInfoRow( + context, + label: AppLocalizations.of(context)!.buildTime, + value: _formatTimestamp(updateInfo.timestamp!), + ), + ], + ], + ), + actions: [ + // Later button + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(loc.updateLater), + ), + + // Download button + FilledButton.icon( + onPressed: () => _launchDownloadUrl(context), + icon: const Icon(Icons.download), + label: Text(loc.downloadUpdate), + ), + ], + ); + } + + /// Build a labeled info row + Widget _buildInfoRow(BuildContext context, {required String label, required String value}) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + ), + Expanded( + child: SelectableText( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + color: Colors.black87, + ), + ), + ), + ], + ); + } + + /// Format timestamp from YYYYMMDD-HHMMSS to readable format + String _formatTimestamp(String timestamp) { + try { + // Parse YYYYMMDD-HHMMSS format + if (timestamp.length >= 15) { + final year = timestamp.substring(0, 4); + final month = timestamp.substring(4, 6); + final day = timestamp.substring(6, 8); + final hour = timestamp.substring(9, 11); + final minute = timestamp.substring(11, 13); + return '$year-$month-$day $hour:$minute UTC'; + } + return timestamp; + } catch (e) { + return timestamp; + } + } + + /// Launch download URL in browser + Future _launchDownloadUrl(BuildContext context) async { + if (updateInfo.downloadUrl == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.downloadUrlNotAvailable), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + try { + final url = Uri.parse(updateInfo.downloadUrl!); + final canLaunch = await canLaunchUrl(url); + + if (!canLaunch) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.cannotOpenDownloadUrl), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); + + // Close dialog after launching download + if (context.mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + debugPrint('[UpdateDialog] Error launching download URL: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.errorOpeningDownload(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + } + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..39e342b --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "meshcore_sar_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.meshcore.sar.meshcore_sar_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..da7fc38 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,35 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin"); + flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_libserialport_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterLibserialportPlugin"); + flutter_libserialport_plugin_register_with_registrar(flutter_libserialport_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..aa14568 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux + file_selector_linux + flutter_avif_linux + flutter_libserialport + record_linux + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..481504a --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "MeshCore SAR"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "MeshCore SAR"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..b82cef6 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,46 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import audioplayers_darwin +import device_info_plus +import file_picker +import file_selector_macos +import flutter_avif_macos +import flutter_blue_plus_darwin +import flutter_libserialport +import flutter_local_notifications +import geolocator_apple +import nsd_macos +import package_info_plus +import path_provider_foundation +import record_macos +import share_plus +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos +import wakelock_plus + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterAvifPlugin.register(with: registry.registrar(forPlugin: "FlutterAvifPlugin")) + FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) + FlutterLibserialportPlugin.register(with: registry.registrar(forPlugin: "FlutterLibserialportPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..b5f3fe2 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,53 @@ +platform :osx, '26.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '26.0' + end + # codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that + # does not compile on x86_64 macOS. Voice messages are iOS-only, so exclude + # all C source files from the macOS codec2_flutter build entirely. + if target.name == 'codec2_flutter' + target.build_configurations.each do |config| + config.build_settings['EXCLUDED_SOURCE_FILE_NAMES'] = '*.c *.cpp' + end + end + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..c15e141 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,47 @@ +PODS: + - codec2_flutter (0.1.0): + - FlutterMacOS + - flutter_avif_macos (0.0.1): + - FlutterMacOS + - flutter_libserialport (0.0.1): + - FlutterMacOS + - libserialport + - FlutterMacOS (1.0.0) + - libserialport (0.1.1) + - nsd_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - codec2_flutter (from `Flutter/ephemeral/.symlinks/plugins/codec2_flutter/macos`) + - flutter_avif_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos`) + - flutter_libserialport (from `Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`) + +SPEC REPOS: + trunk: + - libserialport + +EXTERNAL SOURCES: + codec2_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/codec2_flutter/macos + flutter_avif_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos + flutter_libserialport: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_libserialport/macos + FlutterMacOS: + :path: Flutter/ephemeral + nsd_macos: + :path: Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos + +SPEC CHECKSUMS: + codec2_flutter: 50d930e6a1a187f05891b89300c6ef589651bfbd + flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11 + flutter_libserialport: 2c523cb8d8203fc6d1b0da88190da31ac970eb93 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + libserialport: 1cb25e66ef3c92a8e59c2ea3820302c3fa2268cd + nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3 + +PODFILE CHECKSUM: 467066e8d30d1105257e0877b8cdd20642e7b3db + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fbf5e00 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,823 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 09E5DABA102F8AD2295B6B87 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B449C50DF80550300A0F1C2A /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 4A5D7482F155C540479C76F2 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BCBFC933F46E9E29C5577CFC /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 096B1302757D590E4BF83E0D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* meshcore_sar_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = meshcore_sar_app.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 5F9D5FC9EA4E03926DC3BEF2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 68CB6C3901D198D7C51566FB /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A704F90920C5A0BF5D577939 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + B449C50DF80550300A0F1C2A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BCBFC933F46E9E29C5577CFC /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E86FAC00D343DCCD63721E5B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + F068CA952F0B0BE6C3C93AE0 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4A5D7482F155C540479C76F2 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 09E5DABA102F8AD2295B6B87 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 549922C5B8DA00870316F4F5 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* meshcore_sar_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 549922C5B8DA00870316F4F5 /* Pods */ = { + isa = PBXGroup; + children = ( + 5F9D5FC9EA4E03926DC3BEF2 /* Pods-Runner.debug.xcconfig */, + 68CB6C3901D198D7C51566FB /* Pods-Runner.release.xcconfig */, + E86FAC00D343DCCD63721E5B /* Pods-Runner.profile.xcconfig */, + A704F90920C5A0BF5D577939 /* Pods-RunnerTests.debug.xcconfig */, + F068CA952F0B0BE6C3C93AE0 /* Pods-RunnerTests.release.xcconfig */, + 096B1302757D590E4BF83E0D /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + B449C50DF80550300A0F1C2A /* Pods_Runner.framework */, + BCBFC933F46E9E29C5577CFC /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + EA928AFECF4F9C29A7941E02 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 5D9F2A9B30E86565A6C8C936 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + F431961E17B2B421AFCC65A7 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* meshcore_sar_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 5D9F2A9B30E86565A6C8C936 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + EA928AFECF4F9C29A7941E02 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F431961E17B2B421AFCC65A7 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A704F90920C5A0BF5D577939 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/meshcore_sar_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/meshcore_sar_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F068CA952F0B0BE6C3C93AE0 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/meshcore_sar_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/meshcore_sar_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 096B1302757D590E4BF83E0D /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/meshcore_sar_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/meshcore_sar_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..41cf481 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..96d3fee --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "info": { + "version": 1, + "author": "xcode" + }, + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..5a47b7e Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..4642bcf Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..6b278d4 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..757997b Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..3581391 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..097d07a Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..d8e98e1 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..9b5383e --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = MeshCore SAR + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.meshcore.sar. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..d35e43a --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..47e8855 --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + ITSAppUsesNonExemptEncryption + + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSBluetoothAlwaysUsageDescription + MeshCore SAR needs Bluetooth to discover and communicate with MeshCore devices during SAR operations + NSBluetoothPeripheralUsageDescription + MeshCore SAR needs Bluetooth to discover and communicate with MeshCore devices during SAR operations + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSLocationUsageDescription + MeshCore SAR needs your location to track your team, share your position over the mesh network, and show your location on the map during SAR operations + NSMicrophoneUsageDescription + MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..e69de29 diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..fdc8f97 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1689 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 + url: "https://pub.dev" + source: hosted + version: "6.6.0" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 + url: "https://pub.dev" + source: hosted + version: "5.2.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + bluez: + dependency: transitive + description: + name: bluez + sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545" + url: "https://pub.dev" + source: hosted + version: "0.8.3" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + codec2_flutter: + dependency: "direct main" + description: + path: "." + ref: "03998a2" + resolved-ref: "03998a2542804b872c58340044532fadace0bd4b" + url: "https://github.com/dz0ny/codec2_flutter.git" + source: git + version: "0.1.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dylib: + dependency: transitive + description: + name: dylib + sha256: bf609b3eb6492a3309b3d1dbe8f83a4031de5535dd7686be33487051cc760bb0 + url: "https://pub.dev" + source: hosted + version: "0.3.3" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + exif: + dependency: transitive + description: + name: exif + sha256: a7980fdb3b7ffcd0b035e5b8a5e1eef7cadfe90ea6a4e85ebb62f87b96c7a172 + url: "https://pub.dev" + source: hosted + version: "3.3.0" + fake_async: + dependency: "direct dev" + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: "direct main" + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" + source: hosted + version: "10.3.10" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_avif: + dependency: "direct main" + description: + name: flutter_avif + sha256: "6035f073189c1ae134affa73338c2eca79bf412e9abdb8f8628fdefcccff4444" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_android: + dependency: transitive + description: + name: flutter_avif_android + sha256: "1da135ea0d74225fae3154f954f9ba4cf7aa5e56b30d9851fcfc0ddecab9d8c5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_ios: + dependency: transitive + description: + name: flutter_avif_ios + sha256: "3daaa599d8fe0193d3ade6cafa1fd4f166d4063b8d499c7c311ce1d170b2759f" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_linux: + dependency: transitive + description: + name: flutter_avif_linux + sha256: dd6179edca12c720761b3acc678d7f15208eb8647adbac5a6a90f52d4ba56e2d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_macos: + dependency: transitive + description: + name: flutter_avif_macos + sha256: "8e39f365dfc5713d4527f07a92399184857ea30eced85f82171bfd0a45e2c73b" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_platform_interface: + dependency: transitive + description: + name: flutter_avif_platform_interface + sha256: f5c110bdb7de2d4ab4902bfe2ec331fe24bc19b6d54f00ac4fa165effc0edcda + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_web: + dependency: transitive + description: + name: flutter_avif_web + sha256: db2ff395b08cbfe9116e48ac65c428ba21a9159972ea019f8d7be580ff6cf6e8 + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_windows: + dependency: transitive + description: + name: flutter_avif_windows + sha256: e0c7722df305a6621f4ecdd83098025c705bcf0e8ce9519e03c6f515abcf351d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_background_service: + dependency: "direct main" + description: + name: flutter_background_service + sha256: "70a1c185b1fa1a44f8f14ecd6c86f6e50366e3562f00b2fa5a54df39b3324d3d" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_background_service_android: + dependency: transitive + description: + name: flutter_background_service_android + sha256: ca0793d4cd19f1e194a130918401a3d0b1076c81236f7273458ae96987944a87 + url: "https://pub.dev" + source: hosted + version: "6.3.1" + flutter_background_service_ios: + dependency: transitive + description: + name: flutter_background_service_ios + sha256: "6037ffd45c4d019dab0975c7feb1d31012dd697e25edc05505a4a9b0c7dc9fba" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + flutter_background_service_platform_interface: + dependency: transitive + description: + name: flutter_background_service_platform_interface + sha256: ca74aa95789a8304f4d3f57f07ba404faa86bed6e415f83e8edea6ad8b904a41 + url: "https://pub.dev" + source: hosted + version: "5.1.2" + flutter_blue_plus: + dependency: "direct main" + description: + name: flutter_blue_plus + sha256: "4fba86c513feab2c5cdb9497da0910ed5b50c0fa8d6cec4a26ffb1a558a24eb8" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + flutter_blue_plus_android: + dependency: transitive + description: + name: flutter_blue_plus_android + sha256: "2a73e264685574d1d29dcdd565bad9ecfdf237630237c508ae8b47f5cc791f1d" + url: "https://pub.dev" + source: hosted + version: "8.2.1" + flutter_blue_plus_darwin: + dependency: transitive + description: + name: flutter_blue_plus_darwin + sha256: cfef171db550670cf8110f6eb25baf15d9bc8bad2af29550f9bbc0d8fceaf285 + url: "https://pub.dev" + source: hosted + version: "8.2.1" + flutter_blue_plus_linux: + dependency: transitive + description: + name: flutter_blue_plus_linux + sha256: "5add6c14d2f90672c5e3ded1455b9ca8e6fe44adf9b53cdc60eb3417d38f34fe" + url: "https://pub.dev" + source: hosted + version: "8.2.1" + flutter_blue_plus_platform_interface: + dependency: transitive + description: + name: flutter_blue_plus_platform_interface + sha256: "226fb6753a74a407e3b9975c0fc00de02c490ae655b31c6508cb5790ad30965d" + url: "https://pub.dev" + source: hosted + version: "8.2.1" + flutter_blue_plus_web: + dependency: transitive + description: + name: flutter_blue_plus_web + sha256: "10a7465ccfc50138280abf32c8ab314f5029aa19039628ad9b4d0ed786e0021f" + url: "https://pub.dev" + source: hosted + version: "8.2.1" + flutter_blue_plus_winrt: + dependency: transitive + description: + name: flutter_blue_plus_winrt + sha256: ed894f0ab341f4cece8fa33edc381d46424a7c5bfd0e841d933d0f8c34c86521 + url: "https://pub.dev" + source: hosted + version: "0.0.18" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_compass: + dependency: "direct main" + description: + name: flutter_compass + sha256: "1b4d7e6c95a675ec8482b5c9c9ccf1ebf0ced3dbec59dce28ad609da953de850" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + flutter_driver: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_libserialport: + dependency: "direct main" + description: + name: flutter_libserialport + sha256: f24b5fe6f1821d1c1b3bf2be737ca53f6b0ae78657032a790aa00abb810c8759 + url: "https://pub.dev" + source: hosted + version: "0.6.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" + url: "https://pub.dev" + source: hosted + version: "21.0.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 + url: "https://pub.dev" + source: hosted + version: "11.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_map: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: fdc089aeb4fad05a4f2314f75bbd66c9b7a70668 + url: "https://github.com/fleaflet/flutter_map.git" + source: git + version: "8.2.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + libserialport: + dependency: transitive + description: + name: libserialport + sha256: "392e1592def65282429832ec66fa25e9e163d3b37716b97691482e2406720727" + url: "https://pub.dev" + source: hosted + version: "0.3.0+1" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meshcore_client: + dependency: "direct main" + description: + path: "." + ref: "0bb339f684f709cf5b9d40c07827cc62beb18a4d" + resolved-ref: "0bb339f684f709cf5b9d40c07827cc62beb18a4d" + url: "https://github.com/dz0ny/meshcore_client.git" + source: git + version: "0.1.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nsd: + dependency: "direct main" + description: + name: nsd + sha256: "1611a5c9f61d56ff2973e1488ae04112103e5203b4a7a1fb594b48cfb366fc14" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + nsd_android: + dependency: transitive + description: + name: nsd_android + sha256: "96d2d451c5db0319c37b1b2a38f2d55eb56ae54c0b0d3144c03c19c97436de4a" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + nsd_ios: + dependency: transitive + description: + name: nsd_ios + sha256: "562fffe753543a65190344d3acd0ed80d96c571ac1a05bf10780c5584b5055ac" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + nsd_macos: + dependency: transitive + description: + name: nsd_macos + sha256: "47cd355d84009befe02710c72bf1c1999b24d5f3fb3a3d914f8b77bcaca42542" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + nsd_platform_interface: + dependency: transitive + description: + name: nsd_platform_interface + sha256: b1a5ace6f01ea2ce37f373e52c3b7af4fd7c11de2582ddcc89f4fc00615d9dff + url: "https://pub.dev" + source: hosted + version: "2.2.0" + nsd_windows: + dependency: transitive + description: + name: nsd_windows + sha256: "68b4a256b0be258dbbad0ae789f2e8838d0935a353dac86b17c14c1a05df4ecd" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: "direct overridden" + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + proj4dart: + dependency: "direct main" + description: + name: proj4dart + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840" + url: "https://pub.dev" + source: hosted + version: "12.0.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_common_ffi: + dependency: "direct dev" + description: + name: sqflite_common_ffi + sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20 + url: "https://pub.dev" + source: hosted + version: "2.4.0+3" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + url: "https://pub.dev" + source: hosted + version: "0.11.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + usb_serial: + dependency: "direct main" + description: + name: usb_serial + sha256: a605a600e34e7f28d4e80851ca3999ef747e42e406138887b8a88b8c382a8b07 + url: "https://pub.dev" + source: hosted + version: "0.5.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vibration: + dependency: "direct main" + description: + name: vibration + sha256: "9bb06614c69260f8bd11c80fe01ed7988905cf00e3417d656c2647e41f261d87" + url: "https://pub.dev" + source: hosted + version: "3.1.8" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "4134fbfcd427b59a7a91f8733292e4e9b29a7f1e8224ff0d80f5745fbf0743c6" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + web: + dependency: "direct main" + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + webserial: + dependency: "direct main" + description: + name: webserial + sha256: b39c2175190ee1c75e996bf137bfee3e3e8fd06ee0ec24f0d3c10bdf7b323786 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: "direct main" + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..a934c6d --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,214 @@ +name: meshcore_sar_app +description: "MeshCore SAR (Search & Rescue) app with BLE mesh networking and offline maps" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 2026.0613.2+57 + +environment: + sdk: ^3.9.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + # Internationalization + intl: any + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + # MeshCore BLE protocol client + meshcore_client: + git: + url: https://github.com/dz0ny/meshcore_client.git + ref: 0bb339f684f709cf5b9d40c07827cc62beb18a4d + + # Codec2 ultra-low-bitrate speech codec (FFI plugin) + codec2_flutter: + git: + url: https://github.com/dz0ny/codec2_flutter.git + ref: 03998a2 + ffi: ^2.1.0 + + # Voice recording (raw PCM stream at 8000 Hz) + record: ^6.0.0 + + # Voice playback (plays WAV from file) + audioplayers: ^6.0.0 + + # BLE connectivity + flutter_blue_plus: ^2.0.0 + + # State management + provider: ^6.1.0 + + # Map display + flutter_map: + git: + url: https://github.com/fleaflet/flutter_map.git + ref: master + latlong2: ^0.9.0 + fl_chart: ^1.2.0 + + http: ^1.2.0 + + # Coordinate system projections for WMS (EPSG:3794) + proj4dart: ^3.0.0 + + # Permissions + permission_handler: ^12.0.1 + + # Location services + geolocator: ^14.0.2 + flutter_compass: ^0.8.0 + + # File handling + share_plus: ^12.0.0 + path_provider: ^2.1.5 + file_picker: ^10.3.3 + + # Image messaging (pick + AVIF encode/decode for mesh transmission) + image_picker: ^1.1.2 + flutter_avif: ^3.1.0 + + # Persistent storage + shared_preferences: ^2.3.3 + sqflite: ^2.4.2 + + # Package info + package_info_plus: ^9.0.0 + wakelock_plus: ^1.4.0 + + # Background services + flutter_background_service: ^5.0.13 + + # Notifications + flutter_local_notifications: ^21.0.0 + timezone: ^0.11.0 + + # Vibration + vibration: ^3.1.4 + + # URL launching + url_launcher: ^6.3.0 + + # Cryptographic functions (for channel secret hashing) + crypto: ^3.0.3 + + # XML parsing for GPX import/export + xml: ^6.5.0 + + # Network Service Discovery (Bonjour/mDNS) + nsd: ^4.0.3 + usb_serial: ^0.5.2 + web: ^1.1.1 + flutter_libserialport: ^0.6.0 + webserial: ^1.2.0 + +dev_dependencies: + flutter_driver: + sdk: flutter + + flutter_test: + sdk: flutter + + integration_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + flutter_launcher_icons: "^0.14.4" + fake_async: ^1.3.3 + sqflite_common_ffi: ^2.3.6 + +dependency_overrides: + # Keep local package overrides in pubspec_overrides.yaml so CI uses the + # declared dependency source from this file. + # path_provider_foundation 2.6.0 pulls in package:objective_c as a native + # asset. That framework has been ending up archived with a macOS platform + # slice and fails App Store validation for iOS uploads. + path_provider_foundation: 2.5.1 + +flutter_launcher_icons: + android: "launcher_icon" + ios: true + image_path: "icon.png" + min_sdk_android: 21 # android min sdk min:16, default 21 + web: + generate: true + image_path: "icon.png" + background_color: "#ddd" # background color for web icon, default white + theme_color: "#fff" # theme color for web icon, default white + windows: + generate: true + image_path: "icon.png" + icon_size: 48 # min:48, max:256, default: 48 + macos: + generate: true + image_path: "icon.png" + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # Enable generation of localization files + generate: true + assets: + - screenshots/ios/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/screenshots/ios/IMG_2953.PNG b/screenshots/ios/IMG_2953.PNG new file mode 100644 index 0000000..3c203fc Binary files /dev/null and b/screenshots/ios/IMG_2953.PNG differ diff --git a/screenshots/ios/IMG_2954.PNG b/screenshots/ios/IMG_2954.PNG new file mode 100644 index 0000000..31603e4 Binary files /dev/null and b/screenshots/ios/IMG_2954.PNG differ diff --git a/screenshots/ios/IMG_2955.PNG b/screenshots/ios/IMG_2955.PNG new file mode 100644 index 0000000..0836752 Binary files /dev/null and b/screenshots/ios/IMG_2955.PNG differ diff --git a/screenshots/ios/IMG_2956.PNG b/screenshots/ios/IMG_2956.PNG new file mode 100644 index 0000000..5613f60 Binary files /dev/null and b/screenshots/ios/IMG_2956.PNG differ diff --git a/screenshots/ios/IMG_2957.PNG b/screenshots/ios/IMG_2957.PNG new file mode 100644 index 0000000..a1e00a5 Binary files /dev/null and b/screenshots/ios/IMG_2957.PNG differ diff --git a/scripts/take_screenshots.sh b/scripts/take_screenshots.sh new file mode 100755 index 0000000..b5bf616 --- /dev/null +++ b/scripts/take_screenshots.sh @@ -0,0 +1,282 @@ +#!/bin/bash + +# MeshCore SAR App Screenshot Script +# Captures screenshots on multiple devices for App Store submission + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +OUTPUT_DIR="screenshots" +IOS_OUTPUT_DIR="ios/fastlane/screenshots/en-US" +IOS_SCREENSHOT_WIDTH=1284 +IOS_SCREENSHOT_HEIGHT=2778 +IPAD_SCREENSHOT_WIDTH=2048 +IPAD_SCREENSHOT_HEIGHT=2732 +INTEGRATION_TEST="integration_test/app_screenshots_test.dart" + +# Device configurations for App Store screenshots +# iOS devices (required sizes: 6.7", 6.5", 5.5") +IOS_DEVICES=( + "iPhone 17 Pro Max" # 6.9" - App Store large phone format + "iPad Pro 13-inch (M5)" # 13" - required iPad format +) + +# Android devices (phone + tablet recommended) +ANDROID_DEVICES=( + "pixel_7_pro" # Phone - 1440x3120 + "pixel_tablet" # Tablet - 2560x1600 +) + +echo -e "${BLUE}╔═══════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ MeshCore SAR Screenshot Generator ║${NC}" +echo -e "${BLUE}╔═══════════════════════════════════════════╗${NC}" +echo "" + +# Check if integration test exists +if [ ! -f "$INTEGRATION_TEST" ]; then + echo -e "${RED}❌ Error: Integration test not found at $INTEGRATION_TEST${NC}" + exit 1 +fi + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# Function to list available devices +list_devices() { + echo -e "${YELLOW}📱 Available iOS Simulators:${NC}" + xcrun simctl list devices available | grep -E "iPhone|iPad" | grep -v "unavailable" + echo "" + echo -e "${YELLOW}🤖 Available Android Emulators:${NC}" + emulator -list-avds + echo "" +} + +# Function to take screenshots on iOS +take_ios_screenshots() { + local device_name="$1" + echo -e "${GREEN}📸 Taking screenshots on iOS: $device_name${NC}" + + # Get device ID (UUID is the first parenthesized value) + local device_line=$(xcrun simctl list devices available | grep "$device_name" | grep -v "unavailable" | head -1) + local device_id=$(echo "$device_line" | sed -n 's/.*(\([0-9A-F-]*\)).*/\1/p') + + if [ -z "$device_id" ]; then + echo -e "${RED}❌ Device not found: $device_name${NC}" + echo -e "${YELLOW}💡 Creating simulator: $device_name${NC}" + # Try to create the device (this might fail if device type doesn't exist) + device_id=$(xcrun simctl create "$device_name" "$device_name" 2>&1) + if [ $? -ne 0 ]; then + echo -e "${RED}❌ Failed to create simulator. Skipping...${NC}" + return 1 + fi + fi + + echo -e "${BLUE} Device ID: $device_id${NC}" + + xcrun simctl shutdown "$device_id" 2>/dev/null || true + xcrun simctl boot "$device_id" 2>/dev/null || true + sleep 3 + xcrun simctl uninstall "$device_id" com.meshcore.sar.meshcoreSarApp 2>/dev/null || true + xcrun simctl privacy "$device_id" grant notifications com.meshcore.sar.meshcoreSarApp 2>/dev/null || true + xcrun simctl privacy "$device_id" grant location com.meshcore.sar.meshcoreSarApp 2>/dev/null || true + + # Create device-specific output directory + local device_dir="$IOS_OUTPUT_DIR" + local screenshot_prefix="" + local screenshot_width="$IOS_SCREENSHOT_WIDTH" + local screenshot_height="$IOS_SCREENSHOT_HEIGHT" + local remove_pattern="[0-9][0-9]-*.png" + if [[ "$device_name" == *"iPad"* ]]; then + screenshot_prefix="ipad-" + screenshot_width="$IPAD_SCREENSHOT_WIDTH" + screenshot_height="$IPAD_SCREENSHOT_HEIGHT" + remove_pattern="ipad-*.png" + fi + mkdir -p "$device_dir" + rm -f "$device_dir"/$remove_pattern + + # Run the integration test + SCREENSHOT_OUTPUT_DIR="$device_dir" flutter drive \ + --driver=test_driver/integration_test.dart \ + --target="$INTEGRATION_TEST" \ + -d "$device_id" \ + --dart-define=MESHCORE_SCREENSHOTS=true \ + --dart-define=SCREENSHOT_PREFIX="$screenshot_prefix" \ + --screenshot="$device_dir" || { + echo -e "${RED}❌ Screenshot capture failed on $device_name${NC}" + return 1 + } + + if command -v sips >/dev/null 2>&1; then + for screenshot in "$device_dir"/$remove_pattern; do + sips -z "$screenshot_height" "$screenshot_width" "$screenshot" >/dev/null + done + fi + + echo -e "${GREEN}✅ Completed: $device_name${NC}" + echo "" +} + +# Function to take screenshots on Android +take_android_screenshots() { + local device_name="$1" + echo -e "${GREEN}📸 Taking screenshots on Android: $device_name${NC}" + + # Check if emulator exists + if ! emulator -list-avds | grep -q "^$device_name$"; then + echo -e "${RED}❌ Emulator not found: $device_name${NC}" + echo -e "${YELLOW}💡 Please create the emulator first using Android Studio${NC}" + return 1 + fi + + # Start emulator in background + echo -e "${BLUE} Starting emulator...${NC}" + emulator -avd "$device_name" -no-audio -no-boot-anim & + EMULATOR_PID=$! + + # Wait for emulator to boot + echo -e "${BLUE} Waiting for emulator to boot...${NC}" + adb wait-for-device + sleep 10 + + # Wait for boot to complete + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + echo -e "${BLUE} Still booting...${NC}" + sleep 3 + done + echo -e "${GREEN} Emulator booted${NC}" + + # Create device-specific output directory + local device_dir="$OUTPUT_DIR/android/${device_name}" + mkdir -p "$device_dir" + + # Run the integration test + SCREENSHOT_OUTPUT_DIR="$device_dir" flutter drive \ + --driver=test_driver/integration_test.dart \ + --target="$INTEGRATION_TEST" \ + -d emulator-5554 \ + --dart-define=MESHCORE_SCREENSHOTS=true \ + --screenshot="$device_dir" || { + echo -e "${RED}❌ Screenshot capture failed on $device_name${NC}" + return 1 + } + + # Kill emulator + kill $EMULATOR_PID 2>/dev/null || true + + echo -e "${GREEN}✅ Completed: $device_name${NC}" + echo "" +} + +# Parse command line arguments +PLATFORM="all" +DEVICE_FILTER="" + +while [[ $# -gt 0 ]]; do + case $1 in + --ios) + PLATFORM="ios" + shift + ;; + --android) + PLATFORM="android" + shift + ;; + --device) + DEVICE_FILTER="$2" + shift 2 + ;; + --list) + list_devices + exit 0 + ;; + --help) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --ios Take screenshots on iOS devices only" + echo " --android Take screenshots on Android devices only" + echo " --device Take screenshots on specific device only" + echo " --list List available devices" + echo " --help Show this help message" + echo "" + echo "Examples:" + echo " $0 # All devices" + echo " $0 --ios # iOS only" + echo " $0 --android # Android only" + echo " $0 --device 'iPhone 15 Pro Max' # Specific device" + echo " $0 --list # List available devices" + exit 0 + ;; + *) + echo -e "${RED}❌ Unknown option: $1${NC}" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Ensure test driver exists +DRIVER_FILE="test_driver/integration_test.dart" +if [ ! -f "$DRIVER_FILE" ]; then + echo -e "${RED}❌ Error: Integration test driver not found at $DRIVER_FILE${NC}" + exit 1 +fi + +# Take screenshots +if [ -n "$DEVICE_FILTER" ]; then + # Specific device + echo -e "${BLUE}🎯 Taking screenshots on: $DEVICE_FILTER${NC}" + echo "" + + # Determine if iOS or Android based on device name + if [[ "$DEVICE_FILTER" == *"iPhone"* ]] || [[ "$DEVICE_FILTER" == *"iPad"* ]]; then + take_ios_screenshots "$DEVICE_FILTER" + else + take_android_screenshots "$DEVICE_FILTER" + fi +else + # Multiple devices based on platform + if [ "$PLATFORM" = "all" ] || [ "$PLATFORM" = "ios" ]; then + echo -e "${BLUE}🍎 Taking iOS screenshots...${NC}" + echo "" + for device in "${IOS_DEVICES[@]}"; do + take_ios_screenshots "$device" + done + fi + + if [ "$PLATFORM" = "all" ] || [ "$PLATFORM" = "android" ]; then + echo -e "${BLUE}🤖 Taking Android screenshots...${NC}" + echo "" + for device in "${ANDROID_DEVICES[@]}"; do + take_android_screenshots "$device" + done + fi +fi + +echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ ✅ Screenshot Capture Complete! ║${NC}" +echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}" +echo "" +if [ "$PLATFORM" = "ios" ]; then + echo -e "${BLUE}📁 Screenshots saved to: $IOS_OUTPUT_DIR${NC}" +else + echo -e "${BLUE}📁 Screenshots saved to: $OUTPUT_DIR${NC}" +fi +echo "" +echo -e "${YELLOW}Next steps:${NC}" +if [ "$PLATFORM" = "ios" ]; then + echo -e " 1. Review screenshots in $IOS_OUTPUT_DIR" +else + echo -e " 1. Review screenshots in $OUTPUT_DIR" +fi +echo -e " 2. Organize by device size for App Store" +echo -e " 3. Add captions and localization if needed" +echo "" diff --git a/test/models/channel_test.dart b/test/models/channel_test.dart new file mode 100644 index 0000000..6b4f9f9 --- /dev/null +++ b/test/models/channel_test.dart @@ -0,0 +1,35 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/channel.dart'; + +void main() { + test('hash channels expose deterministic psk_base64', () { + final channel = Channel.create(index: 3, name: '#ops'); + + expect(channel.isHashChannel, isTrue); + expect(channel.pskBase64, 'O2RN43fDLHh5NgWiWqkVvw=='); + expect( + Channel.pskBase64ForHashChannelName('#ops'), + 'O2RN43fDLHh5NgWiWqkVvw==', + ); + }); + + test('normal channels reject derived hashtag psk export helper', () { + expect( + () => Channel.pskBase64ForHashChannelName('ops'), + throwsArgumentError, + ); + }); + + test('normal channels still expose their stored psk_base64', () { + final channel = Channel.create( + index: 4, + name: 'ops', + explicitSecret: Uint8List.fromList(List.generate(16, (i) => i)), + ); + + expect(channel.isHashChannel, isFalse); + expect(channel.pskBase64, 'AAECAwQFBgcICQoLDA0ODw=='); + }); +} diff --git a/test/models/contact_route_codec_test.dart b/test/models/contact_route_codec_test.dart new file mode 100644 index 0000000..d8538f9 --- /dev/null +++ b/test/models/contact_route_codec_test.dart @@ -0,0 +1,172 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; + +void main() { + Contact buildContact({ + required int signedPathLen, + required Uint8List outPath, + }) { + return Contact( + publicKey: Uint8List.fromList(List.generate(32, (index) => index)), + type: ContactType.chat, + flags: 0, + outPathLen: signedPathLen, + outPath: outPath, + advName: 'Route Contact', + lastAdvert: 0, + advLat: 0, + advLon: 0, + lastMod: 0, + ); + } + + group('ContactRouteCodec.parse', () { + test('parses 1-byte hop routes', () { + final route = ContactRouteCodec.parse('AA,BB,CC'); + + expect(route.hashSize, 1); + expect(route.hopCount, 3); + expect(route.encodedPathLen, 0x03); + expect(route.canonicalText, 'AA,BB,CC'); + expect(route.pathBytes, [0xAA, 0xBB, 0xCC]); + }); + + test('parses 2-byte hop routes', () { + final route = ContactRouteCodec.parse('AABB,CCDD'); + + expect(route.hashSize, 2); + expect(route.hopCount, 2); + expect(route.encodedPathLen, 0x42); + expect(route.canonicalText, 'AABB,CCDD'); + expect(route.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]); + }); + + test('parses 3-byte hop routes', () { + final route = ContactRouteCodec.parse('AABBCC,DDEEFF'); + + expect(route.hashSize, 3); + expect(route.hopCount, 2); + expect(route.encodedPathLen, 0x82); + expect(route.signedEncodedPathLen, -126); + expect(route.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + }); + + test('accepts colon-separated hops and normalizes output', () { + final route = ContactRouteCodec.parse('AA:BB,CC:DD'); + + expect(route.hashSize, 2); + expect(route.canonicalText, 'AABB,CCDD'); + }); + + test('rejects mixed hop widths', () { + expect( + () => ContactRouteCodec.parse('AA,AABB'), + throwsA(isA()), + ); + }); + + test('rejects routes that do not match the configured hash size', () { + expect( + () => ContactRouteCodec.parse('AABB,CCDD', expectedHashSize: 1), + throwsA(isA()), + ); + }); + + test('rejects invalid tokens', () { + expect( + () => ContactRouteCodec.parse('AA,XYZ'), + throwsA(isA()), + ); + expect( + () => ContactRouteCodec.parse('AAA'), + throwsA(isA()), + ); + }); + + test('rejects routes over 64 bytes', () { + final tooLong = List.filled(22, 'AABBCC').join(','); + expect( + () => ContactRouteCodec.parse(tooLong), + throwsA(isA()), + ); + }); + }); + + group('Contact route helpers', () { + test('interprets signed 3-byte descriptors as valid routes', () { + final outPath = Uint8List(64) + ..setRange(0, 6, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + final contact = buildContact(signedPathLen: -126, outPath: outPath); + + expect(contact.routeHasPath, isTrue); + expect(contact.routeHashSize, 3); + expect(contact.routeHopCount, 2); + expect(contact.routeCanonicalText, 'AABBCC,DDEEFF'); + expect(contact.routeSupportsLegacyRawTransport, isTrue); + }); + + test('treats -1 as unknown route', () { + final contact = buildContact(signedPathLen: -1, outPath: Uint8List(0)); + + expect(contact.routeHasPath, isFalse); + expect(contact.routeSummary, 'Flood/Unknown'); + }); + }); + + group('ContactRouteCodec.toLegacyRawPath', () { + test('zero-hop direct (0x40) → empty legacy path', () { + final legacy = ContactRouteCodec.toLegacyRawPath(0x40, Uint8List(0)); + + expect(legacy.length, 0); + expect(legacy.path, isEmpty); + }); + + test('1-hop 2-byte hash (0x41) → first byte of the hash', () { + final legacy = ContactRouteCodec.toLegacyRawPath( + 0x41, + Uint8List.fromList([0x02, 0x62]), + ); + + expect(legacy.length, 1); + expect(legacy.path, [0x02]); + }); + + test('2-hop 2-byte hash (0x42) → first byte of each hash', () { + final legacy = ContactRouteCodec.toLegacyRawPath( + 0x42, + Uint8List.fromList([0xAA, 0xBB, 0xCC, 0xDD]), + ); + + expect(legacy.length, 2); + expect(legacy.path, [0xAA, 0xCC]); + }); + + test('signed 3-byte descriptor (-126 / 0x82) down-samples each hop', () { + final outPath = Uint8List(64) + ..setRange(0, 6, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + final legacy = ContactRouteCodec.toLegacyRawPath(-126, outPath); + + expect(legacy.length, 2); + expect(legacy.path, [0xAA, 0xDD]); + }); + + test('legacy 1-byte hops (< 0x40) pass through trimmed', () { + final legacy = ContactRouteCodec.toLegacyRawPath( + 0x03, + Uint8List.fromList([0xAA, 0xBB, 0xCC, 0x00, 0x00]), + ); + + expect(legacy.length, 3); + expect(legacy.path, [0xAA, 0xBB, 0xCC]); + }); + + test('unknown descriptor (0xFF) collapses to direct', () { + final legacy = ContactRouteCodec.toLegacyRawPath(-1, Uint8List(0)); + + expect(legacy.length, 0); + expect(legacy.path, isEmpty); + }); + }); +} diff --git a/test/models/custom_map_config_test.dart b/test/models/custom_map_config_test.dart new file mode 100644 index 0000000..6ca568d --- /dev/null +++ b/test/models/custom_map_config_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_sar_app/models/custom_map_config.dart'; + +void main() { + test('displayBounds stays within valid LatLng limits for tall images', () { + const config = CustomMapConfig( + filePath: '/tmp/map.png', + displayName: 'Tall Map', + mapId: 'map-id', + imageWidth: 2400, + imageHeight: 3213, + ); + + expect(config.displayBounds.north, lessThanOrEqualTo(90)); + expect(config.displayBounds.south, greaterThanOrEqualTo(-90)); + expect(config.displayBounds.east, lessThanOrEqualTo(180)); + expect(config.displayBounds.west, greaterThanOrEqualTo(-180)); + }); + + test('display point conversion preserves stored coordinates', () { + const config = CustomMapConfig( + filePath: '/tmp/map.png', + displayName: 'Tall Map', + mapId: 'map-id', + imageWidth: 2400, + imageHeight: 3213, + ); + const storedPoint = LatLng(1600, 1200); + + final displayPoint = config.toDisplayPoint(storedPoint); + final roundTrip = config.fromDisplayPoint(displayPoint); + + expect(roundTrip.latitude, closeTo(storedPoint.latitude, 0.001)); + expect(roundTrip.longitude, closeTo(storedPoint.longitude, 0.001)); + }); +} diff --git a/test/models/message_reception_details_test.dart b/test/models/message_reception_details_test.dart new file mode 100644 index 0000000..2982281 --- /dev/null +++ b/test/models/message_reception_details_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/message_reception_details.dart'; + +void main() { + test('drops impossible transmit estimate for received messages', () { + expect( + sanitizeEstimatedTransmitMs( + estimatedTransmitMs: 16 * 60 * 1000 + 54 * 1000, + senderToReceiptMs: 4200, + ), + isNull, + ); + }); + + test( + 'keeps close transmit estimate despite second-level timestamp rounding', + () { + expect( + sanitizeEstimatedTransmitMs( + estimatedTransmitMs: 1800, + senderToReceiptMs: 900, + ), + 1800, + ); + }, + ); + + test('round trips reception details json', () { + final details = MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000), + packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + rssiDbm: -92, + snrDb: 7.5, + pathBytes: const [0xAA, 0xBB, 0xCC], + senderToReceiptMs: 4200, + estimatedTransmitMs: 1800, + postTransmitDelayMs: 2400, + ); + + final decoded = MessageReceptionDetails.fromJson(details.toJson()); + + expect(decoded, isNotNull); + expect(decoded!.rssiDbm, -92); + expect(decoded.snrDb, 7.5); + expect(decoded.pathBytesHex, 'aa:bb:cc'); + expect(decoded.senderToReceiptMs, 4200); + expect(decoded.estimatedTransmitMs, 1800); + expect(decoded.postTransmitDelayMs, 2400); + }); +} diff --git a/test/providers/app_provider_channel_info_test.dart b/test/providers/app_provider_channel_info_test.dart new file mode 100644 index 0000000..e1e3680 --- /dev/null +++ b/test/providers/app_provider_channel_info_test.dart @@ -0,0 +1,355 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/models/device_info.dart'; +import 'package:meshcore_sar_app/providers/app_provider.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/drawing_provider.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart' as ip; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/providers/voice_provider.dart'; +import 'package:meshcore_sar_app/services/location_tracking_service.dart'; +import 'package:meshcore_sar_app/services/voice_codec_service.dart'; +import 'package:meshcore_sar_app/services/voice_player_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Uint8List _ownPublicKey() => + Uint8List.fromList([1, 2, 3, 4, 5, 6, ...List.filled(26, 0)]); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + final locationTrackingService = LocationTrackingService(); + locationTrackingService.fastLocationUpdatesEnabled = false; + locationTrackingService.fastLocationChannelIdx = null; + locationTrackingService.isTracking = false; + }); + + group('AppProvider channel info handling', () { + test('treats zeroed unnamed non-public channel as deleted', () { + expect(AppProvider.isDeletedChannelInfo(2, '', Uint8List(16)), isTrue); + }); + + test('keeps unnamed non-public channel when secret is configured', () { + final secret = Uint8List.fromList([1, ...List.filled(15, 0)]); + + expect(AppProvider.isDeletedChannelInfo(2, '', secret), isFalse); + expect(AppProvider.channelContactName(2, ''), 'Channel 2'); + }); + }); + + group('AppProvider self replay handling', () { + test('prefers self name over meshcore device name', () { + expect( + AppProvider.preferredSelfDisplayName( + deviceName: 'MeshCore-dz0ny (SI)', + selfName: 'dz0ny (SI)', + ), + 'dz0ny (SI)', + ); + }); + + test('ignores direct self replay without hops', () { + final message = Message( + id: 'dm-self', + messageType: MessageType.contact, + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello', + receivedAt: DateTime.now(), + ); + + expect( + AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: _ownPublicKey(), + ownName: 'dz0ny (SI)', + ), + isTrue, + ); + }); + + test('ignores room self replay without hops', () { + final message = Message( + id: 'room-self', + messageType: MessageType.contact, + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + recipientPublicKey: Uint8List.fromList([9, 9, 9, 9, 9, 9]), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello room', + receivedAt: DateTime.now(), + ); + + expect( + AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: _ownPublicKey(), + ownName: 'dz0ny (SI)', + ), + isTrue, + ); + }); + + test('keeps direct self replay when it traversed hops', () { + final message = Message( + id: 'dm-self-routed', + messageType: MessageType.contact, + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello', + receivedAt: DateTime.now(), + ); + + expect( + AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: _ownPublicKey(), + ownName: 'dz0ny (SI)', + ), + isFalse, + ); + }); + + test('ignores channel self replay by self name without hops', () { + final message = Message( + id: 'channel-self', + messageType: MessageType.channel, + senderName: 'dz0ny (SI)', + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello public', + receivedAt: DateTime.now(), + ); + + expect( + AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: _ownPublicKey(), + ownName: 'dz0ny (SI)', + ), + isTrue, + ); + }); + + test('keeps channel self replay when it traversed hops', () { + final message = Message( + id: 'channel-self-routed', + messageType: MessageType.channel, + senderName: 'dz0ny (SI)', + channelIdx: 0, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello public', + receivedAt: DateTime.now(), + ); + + expect( + AppProvider.shouldIgnoreSelfReplay( + message: message, + ownPublicKey: _ownPublicKey(), + ownName: 'dz0ny (SI)', + ), + isFalse, + ); + }); + }); + + group('AppProvider channel location sharing state', () { + test('clears app fallback sharing state when device disconnects', () async { + final harness = _AppProviderHarness( + connectionProvider: _FakeConnectionProvider(isConnected: true), + ); + final locationTrackingService = harness.appProvider.locationTrackingService; + + await locationTrackingService.updateFastLocationChannelIdx(3); + await locationTrackingService.setFastLocationUpdatesEnabled(true); + + harness.connectionProvider.setConnected(false); + await Future.delayed(Duration.zero); + + expect(locationTrackingService.fastLocationUpdatesEnabled, isFalse); + expect(locationTrackingService.fastLocationChannelIdx, isNull); + expect(harness.appProvider.channelLocationSharingModeForChannel(3), isNull); + + await harness.dispose(); + }); + + test('preserves hardware sharing state when device disconnects', () async { + final harness = _AppProviderHarness( + connectionProvider: _FakeConnectionProvider( + isConnected: true, + customVars: const {'gps': '1', 'fast_gps_channel': '7'}, + ), + ); + + await harness.appProvider.refreshChannelLocationSharingState(); + + expect( + harness.appProvider.channelLocationSharingModeForChannel(7), + ChannelLocationSharingMode.hardware, + ); + + harness.connectionProvider.setConnected(false); + await Future.delayed(Duration.zero); + + expect( + harness.appProvider.channelLocationSharingModeForChannel(7), + ChannelLocationSharingMode.hardware, + ); + + await harness.dispose(); + }); + + test('clears app fallback sharing state when device connects', () async { + final harness = _AppProviderHarness( + connectionProvider: _FakeConnectionProvider(isConnected: false), + ); + final locationTrackingService = harness.appProvider.locationTrackingService; + + await locationTrackingService.updateFastLocationChannelIdx(5); + await locationTrackingService.setFastLocationUpdatesEnabled(true); + + harness.connectionProvider.setConnected(true); + await Future.delayed(Duration.zero); + + expect(locationTrackingService.fastLocationUpdatesEnabled, isFalse); + expect(locationTrackingService.fastLocationChannelIdx, isNull); + expect(harness.appProvider.channelLocationSharingModeForChannel(5), isNull); + + await harness.dispose(); + }); + }); +} + +class _AppProviderHarness { + final _FakeConnectionProvider connectionProvider; + final ContactsProvider contactsProvider; + final MessagesProvider messagesProvider; + final DrawingProvider drawingProvider; + final ChannelsProvider channelsProvider; + final VoiceProvider voiceProvider; + final ip.ImageProvider imageProvider; + late final AppProvider appProvider; + + _AppProviderHarness({required this.connectionProvider}) + : contactsProvider = ContactsProvider(), + messagesProvider = MessagesProvider(), + drawingProvider = DrawingProvider(), + channelsProvider = ChannelsProvider(), + voiceProvider = VoiceProvider( + codec: VoiceCodecService(), + player: _FakeVoicePlayerService(), + ), + imageProvider = ip.ImageProvider() { + appProvider = AppProvider( + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + messagesProvider: messagesProvider, + drawingProvider: drawingProvider, + channelsProvider: channelsProvider, + voiceProvider: voiceProvider, + imageProvider: imageProvider, + ); + } + + Future dispose() async { + for (var i = 0; i < 10; i++) { + if (appProvider.trafficStatsReportingService.isInitialized) { + break; + } + await Future.delayed(const Duration(milliseconds: 10)); + } + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + } +} + +class _FakeConnectionProvider extends ConnectionProvider { + DeviceInfo _deviceInfo; + + _FakeConnectionProvider({ + required bool isConnected, + Map? customVars, + }) : _deviceInfo = DeviceInfo( + connectionState: isConnected + ? ConnectionState.connected + : ConnectionState.disconnected, + ), + _customVars = customVars ?? {}; + + final Map _customVars; + + @override + DeviceInfo get deviceInfo => _deviceInfo; + + void setConnected(bool isConnected) { + _deviceInfo = _deviceInfo.copyWith( + connectionState: isConnected + ? ConnectionState.connected + : ConnectionState.disconnected, + ); + notifyListeners(); + } + + @override + Future> getCustomVars() async => + Map.from(_customVars); +} + +class _FakeVoicePlayerService implements VoicePlayerService { + final StreamController _events = StreamController.broadcast(); + bool _isPlaying = false; + + @override + bool get isPlaying => _isPlaying; + + @override + Duration get position => Duration.zero; + + @override + Duration get duration => Duration.zero; + + @override + Stream get events => _events.stream; + + @override + Future play(Int16List pcmSamples, {required int sampleRateHz}) async { + _isPlaying = true; + _events.add(null); + } + + @override + Future stop() async { + _isPlaying = false; + _events.add(null); + } + + @override + void dispose() { + _events.close(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/providers/channels_provider_test.dart b/test/providers/channels_provider_test.dart new file mode 100644 index 0000000..df04357 --- /dev/null +++ b/test/providers/channels_provider_test.dart @@ -0,0 +1,60 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/channel.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; + +void main() { + group('ChannelsProvider device sync preparation', () { + test('clears runtime channel state before sync', () { + final provider = ChannelsProvider(); + + provider.addOrUpdateChannel( + index: 2, + name: 'Ops', + secret: Uint8List.fromList(List.filled(16, 7)), + ); + provider.selectChannel(2); + + provider.prepareForDeviceSync(); + + expect(provider.channels, isEmpty); + expect(provider.selectedChannelIndex, 0); + expect(provider.selectedChannel, isNull); + }); + }); + + group('ChannelsProvider mesh hash lookup', () { + test('resolves a unique channel display name by hash byte', () { + final provider = ChannelsProvider(); + final channel = Channel.create(index: 3, name: '#ops'); + + provider.addOrUpdateChannelObject(channel); + + expect( + provider.getUniqueChannelDisplayNameByHashByte(channel.hashByte), + '#ops', + ); + }); + + test('does not resolve ambiguous hash matches', () { + final provider = ChannelsProvider(); + final secret = Uint8List.fromList(List.filled(16, 7)); + final firstChannel = Channel.create( + index: 1, + name: 'Ops 1', + explicitSecret: secret, + ); + + provider.addOrUpdateChannelObject(firstChannel); + provider.addOrUpdateChannelObject( + Channel.create(index: 2, name: 'Ops 2', explicitSecret: secret), + ); + + expect( + provider.getUniqueChannelDisplayNameByHashByte(firstChannel.hashByte), + isNull, + ); + }); + }); +} diff --git a/test/providers/connection_provider_channel_slot_test.dart b/test/providers/connection_provider_channel_slot_test.dart new file mode 100644 index 0000000..2e232c6 --- /dev/null +++ b/test/providers/connection_provider_channel_slot_test.dart @@ -0,0 +1,86 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; + +void main() { + group('ConnectionProvider channel slot occupancy', () { + test('treats non-zero secret as configured', () { + expect( + ConnectionProvider.channelHasConfiguredSecret(Uint8List(16)), + isFalse, + ); + expect( + ConnectionProvider.channelHasConfiguredSecret( + Uint8List.fromList([1, ...List.filled(15, 0)]), + ), + isTrue, + ); + }); + + test('treats duplicate hashtag channels as conflicts', () { + expect( + ConnectionProvider.isDuplicateChannelName( + requestedName: '#sar', + existingName: '#sar', + ), + isTrue, + ); + }); + + test('allows private channels with the same name to coexist', () { + expect( + ConnectionProvider.isDuplicateChannelName( + requestedName: 'Ops', + existingName: 'Ops', + ), + isFalse, + ); + }); + + test('does not treat arbitrary empty channel info as deletion', () { + final provider = ConnectionProvider(); + expect( + provider.shouldTreatChannelInfoAsDeleted(3, '', Uint8List(16)), + isFalse, + ); + }); + + test('finds first missing custom channel slot', () { + expect( + ConnectionProvider.firstAvailableChannelSlot( + occupiedIndices: {1, 2, 4}, + maxChannels: 6, + ), + 3, + ); + }); + + test('returns null when all custom channel slots are occupied', () { + expect( + ConnectionProvider.firstAvailableChannelSlot( + occupiedIndices: {1, 2, 3, 4}, + maxChannels: 5, + ), + isNull, + ); + }); + + test('private channels with same name do not block slot selection', () { + expect( + ConnectionProvider.isDuplicateChannelName( + requestedName: 'RPT_admins', + existingName: 'RPT_admins', + ), + isFalse, + ); + expect( + ConnectionProvider.firstAvailableChannelSlot( + occupiedIndices: {1, 2, 3}, + maxChannels: 6, + ), + 4, + ); + }); + }); +} diff --git a/test/providers/contacts_provider_profile_scope_test.dart b/test/providers/contacts_provider_profile_scope_test.dart new file mode 100644 index 0000000..0f13e31 --- /dev/null +++ b/test/providers/contacts_provider_profile_scope_test.dart @@ -0,0 +1,69 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/services/contact_storage_service.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Contact createContact({required Uint8List key, required String name}) { + return Contact( + publicKey: key, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (46.0569 * 1e6).round(), + advLon: (14.5058 * 1e6).round(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'default', + ); + }); + + test( + 'initializeEarly respects the active profile storage namespace', + () async { + final storage = ContactStorageService(); + final defaultContact = createContact( + key: Uint8List.fromList(List.filled(32, 1)), + name: 'Default Contact', + ); + final alphaContact = createContact( + key: Uint8List.fromList(List.filled(32, 2)), + name: 'Alpha Contact', + ); + + await storage.saveContacts([defaultContact]); + await storage.saveContacts([alphaContact], namespace: 'alpha'); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); + + final provider = ContactsProvider(); + await provider.initializeEarly(); + + final names = provider.contacts + .where((contact) => !contact.isChannel) + .map((contact) => contact.advName) + .toList(); + + expect(names, ['Alpha Contact']); + expect(provider.storageNamespace, 'alpha'); + }, + ); +} diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart new file mode 100644 index 0000000..3a9e81b --- /dev/null +++ b/test/providers/contacts_provider_test.dart @@ -0,0 +1,1092 @@ +import 'dart:typed_data'; + +import 'package:geolocator/geolocator.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/contact_group.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; +import 'package:meshcore_sar_app/utils/fast_gps_packet.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Uint8List createPublicKey(int seed) { + return Uint8List.fromList(List.generate(32, (index) => seed + index)); + } + + Contact createContact({ + required Uint8List key, + required ContactType type, + String? name, + }) { + return Contact( + publicKey: key, + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name ?? 'Test Contact', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (46.0569 * 1e6).toInt(), + advLon: (14.5058 * 1e6).toInt(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + group('ContactsProvider.updateTelemetry', () { + late ContactsProvider provider; + late Uint8List publicKey; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + publicKey = Uint8List.fromList([ + 0xAA, + 0xBB, + 0xCC, + 0xDD, + 0xEE, + 0xFF, + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, + 0x06, + 0x07, + 0x08, + 0x09, + 0x0A, + 0x0B, + 0x0C, + 0x0D, + 0x0E, + 0x0F, + 0x10, + 0x11, + 0x12, + 0x13, + 0x14, + 0x15, + 0x16, + 0x17, + 0x18, + 0x19, + 0x1A, + ]); + + provider.addOrUpdateContact( + Contact( + publicKey: publicKey, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Test Contact', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (46.0569 * 1e6).toInt(), + advLon: (14.5058 * 1e6).toInt(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + }); + + test('ignores telemetry GPS at 0,0 and keeps advert location', () { + final lppData = CayenneLppParser.createGpsData( + latitude: 0.0, + longitude: 0.0, + ); + + provider.updateTelemetry(publicKey.sublist(0, 6), lppData); + final updated = provider.findContactByKey(publicKey)!; + + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, isNull); + expect(updated.displayLocation, isNotNull); + expect(updated.displayLocation!.latitude, closeTo(46.0569, 0.000001)); + expect(updated.displayLocation!.longitude, closeTo(14.5058, 0.000001)); + }); + + test('keeps valid telemetry GPS and uses it for display location', () { + final lppData = CayenneLppParser.createGpsData( + latitude: 45.0001, + longitude: 13.9999, + ); + + provider.updateTelemetry(publicKey.sublist(0, 6), lppData); + final updated = provider.findContactByKey(publicKey)!; + + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, isNotNull); + expect( + updated.telemetry!.gpsLocation!.latitude, + closeTo(45.0001, 0.0001), + ); + expect( + updated.telemetry!.gpsLocation!.longitude, + closeTo(13.9999, 0.0001), + ); + expect(updated.displayLocation, isNotNull); + expect(updated.displayLocation!.latitude, closeTo(45.0001, 0.0001)); + expect(updated.displayLocation!.longitude, closeTo(13.9999, 0.0001)); + expect(updated.advLat, equals((45.0001 * 1e6).round())); + expect(updated.advLon, equals((13.9999 * 1e6).round())); + }); + + test( + 'clears telemetry gps on refresh when gps is invalid or missing but keeps fallback display location', + () { + final contactTypes = [ + ContactType.chat, + ContactType.repeater, + ContactType.sensor, + ContactType.room, + ContactType.channel, + ]; + + for (var i = 0; i < contactTypes.length; i++) { + final scopedProvider = ContactsProvider(); + final scopedKey = createPublicKey(16 + i); + final contactType = contactTypes[i]; + scopedProvider.addOrUpdateContact( + createContact( + key: scopedKey, + type: contactType, + name: 'Contact ${contactType.name}', + ), + ); + + final validGps = CayenneLppParser.createGpsData( + latitude: 45.1234, + longitude: 13.8765, + ); + scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), validGps); + + // No GPS frame should clear telemetry GPS but keep the last known + // contact location as display fallback. + final batteryOnly = CayenneLppParser.createBatteryData(3.8); + scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), batteryOnly); + + var updated = scopedProvider.findContactByKey(scopedKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, isNull); + expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001)); + expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001)); + + // Invalid 0,0 GPS frame should behave the same way. + final invalidGps = CayenneLppParser.createGpsData( + latitude: 0.0, + longitude: 0.0, + ); + scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), invalidGps); + + updated = scopedProvider.findContactByKey(scopedKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, isNull); + expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001)); + expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001)); + } + }, + ); + + test( + 'retains last known gps when a contact refresh arrives without location', + () { + final firstFix = CayenneLppParser.createGpsData( + latitude: 45.1234, + longitude: 13.8765, + ); + provider.updateTelemetry(publicKey.sublist(0, 6), firstFix); + + provider.addOrUpdateContact( + Contact( + publicKey: publicKey, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Test Contact', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, isNotNull); + expect( + updated.telemetry!.gpsLocation!.latitude, + closeTo(45.1234, 0.0001), + ); + expect( + updated.telemetry!.gpsLocation!.longitude, + closeTo(13.8765, 0.0001), + ); + expect(updated.advLat, equals((45.1234 * 1e6).round())); + expect(updated.advLon, equals((13.8765 * 1e6).round())); + expect(updated.displayLocation, isNotNull); + expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001)); + expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001)); + }, + ); + + test('retains existing telemetry when contact refresh omits telemetry', () { + final initialTelemetry = ContactTelemetry( + gpsLocation: const LatLng(45.1234, 13.8765), + batteryPercentage: 76.5, + batteryMilliVolts: 3890, + temperature: 21.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 5)), + humidity: 62.0, + pressure: 1008.4, + extraSensorData: const {'co2': 415.0}, + ); + + provider.addOrUpdateContact( + createContact( + key: publicKey, + type: ContactType.chat, + ).copyWith(telemetry: initialTelemetry), + ); + + provider.addOrUpdateContact( + Contact( + publicKey: publicKey, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Test Contact Refreshed', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765)); + expect(updated.telemetry!.batteryPercentage, equals(76.5)); + expect(updated.telemetry!.batteryMilliVolts, equals(3890)); + expect(updated.telemetry!.temperature, equals(21.5)); + expect(updated.telemetry!.humidity, equals(62.0)); + expect(updated.telemetry!.pressure, equals(1008.4)); + expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0)); + }); + + test('retains existing telemetry during bulk contacts sync', () { + final initialTelemetry = ContactTelemetry( + gpsLocation: const LatLng(45.1234, 13.8765), + batteryPercentage: 76.5, + batteryMilliVolts: 3890, + temperature: 21.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 5)), + humidity: 62.0, + pressure: 1008.4, + extraSensorData: const {'co2': 415.0}, + ); + + provider.addOrUpdateContact( + createContact( + key: publicKey, + type: ContactType.chat, + ).copyWith(telemetry: initialTelemetry), + ); + + provider.addContacts([ + Contact( + publicKey: publicKey, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Synced Contact', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ]); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765)); + expect(updated.telemetry!.batteryPercentage, equals(76.5)); + expect(updated.telemetry!.batteryMilliVolts, equals(3890)); + expect(updated.telemetry!.temperature, equals(21.5)); + expect(updated.telemetry!.humidity, equals(62.0)); + expect(updated.telemetry!.pressure, equals(1008.4)); + expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0)); + }); + + test( + 'retains scalar telemetry but wipes stale extra sensor fields on refresh', + () { + final fullTelemetry = ContactTelemetry( + gpsLocation: const LatLng(46.0569, 14.5058), + batteryPercentage: 54.0, + batteryMilliVolts: 3780, + temperature: 19.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 2)), + humidity: 58.0, + pressure: 1011.2, + extraSensorData: const {'pm25': 8.0}, + ); + + provider.addOrUpdateContact( + createContact( + key: publicKey, + type: ContactType.chat, + ).copyWith(telemetry: fullTelemetry), + ); + + final batteryOnly = CayenneLppParser.createBatteryData(3.95); + provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, isNull); + expect(updated.displayLocation, const LatLng(46.0569, 14.5058)); + expect(updated.telemetry!.batteryMilliVolts, isNotNull); + expect(updated.telemetry!.batteryPercentage, isNotNull); + expect(updated.telemetry!.temperature, equals(19.5)); + expect(updated.telemetry!.humidity, equals(58.0)); + expect(updated.telemetry!.pressure, equals(1011.2)); + expect( + updated.telemetry!.extraSensorData, + containsPair('__source_channel:battery', 0), + ); + expect( + updated.telemetry!.extraSensorData, + containsPair('__source_channel:voltage', 0), + ); + expect(updated.telemetry!.extraSensorData, isNot(contains('pm25'))); + }, + ); + + test( + 'replaces old source-channel mappings when a metric moves channels', + () { + final initialTelemetry = ContactTelemetry( + gpsLocation: null, + batteryPercentage: null, + batteryMilliVolts: null, + temperature: 21.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 2)), + humidity: null, + pressure: null, + extraSensorData: const { + '__source_channel:temperature': 2, + 'temperature_2': 21.5, + 'humidity_4': 66.0, + }, + ); + + provider.addOrUpdateContact( + createContact( + key: publicKey, + type: ContactType.chat, + ).copyWith(telemetry: initialTelemetry), + ); + + final movedChannelTelemetry = CayenneLppParser.createTemperatureData( + 23.5, + channel: 3, + ); + + provider.updateTelemetry( + publicKey.sublist(0, 6), + movedChannelTelemetry, + ); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.temperature, closeTo(23.5, 0.1)); + expect( + updated.telemetry!.extraSensorData, + containsPair('__source_channel:temperature', 3), + ); + expect( + updated.telemetry!.extraSensorData, + containsPair('temperature_3', closeTo(23.5, 0.1)), + ); + expect( + updated.telemetry!.extraSensorData, + isNot(contains('temperature_2')), + ); + expect( + updated.telemetry!.extraSensorData, + isNot(contains('humidity_4')), + ); + }, + ); + + test('builds message snapshot from latest valid telemetry', () { + final telemetryData = CayenneLppParser.createGpsData( + latitude: 45.0001, + longitude: 13.9999, + ); + + provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData); + final contact = provider.findContactByKey(publicKey)!; + final snapshot = provider.buildMessageContactLocationSnapshot( + contact, + capturedAt: DateTime.now(), + ); + + expect(snapshot, isNotNull); + expect(snapshot!.source, equals('telemetry')); + expect(snapshot.location.latitude, closeTo(45.0001, 0.0001)); + expect(snapshot.location.longitude, closeTo(13.9999, 0.0001)); + }); + + test('builds message snapshot from advert when telemetry is invalid', () { + final invalidTelemetry = CayenneLppParser.createGpsData( + latitude: 0.0, + longitude: 0.0, + ); + + provider.updateTelemetry(publicKey.sublist(0, 6), invalidTelemetry); + final contact = provider.findContactByKey(publicKey)!; + final snapshot = provider.buildMessageContactLocationSnapshot( + contact, + capturedAt: DateTime.now(), + ); + + expect(snapshot, isNotNull); + expect(snapshot!.source, equals('advert')); + expect(snapshot.location.latitude, closeTo(46.0569, 0.000001)); + expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); + }); + + test( + 'persists last valid telemetry gps on the contact across reloads', + () async { + final telemetryData = CayenneLppParser.createGpsData( + latitude: 45.0001, + longitude: 13.9999, + ); + + provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData); + await Future.delayed(Duration.zero); + + final reloadedProvider = ContactsProvider(); + await reloadedProvider.initializeEarly(); + + final reloaded = reloadedProvider.findContactByKey(publicKey)!; + expect(reloaded.advLat, equals((45.0001 * 1e6).round())); + expect(reloaded.advLon, equals((13.9999 * 1e6).round())); + expect(reloaded.advertLocation, isNotNull); + expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001)); + expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001)); + }, + ); + + test('prefers a local name override and preserves it across refreshes', () { + provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One'); + + final renamed = provider.findContactByKey(publicKey)!; + expect(renamed.nameOverride, 'Rescue One'); + expect(renamed.displayName, 'Rescue One'); + + provider.addOrUpdateContact( + Contact( + publicKey: publicKey, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Updated Advertised Name', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (46.0569 * 1e6).toInt(), + advLon: (14.5058 * 1e6).toInt(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + + final refreshed = provider.findContactByKey(publicKey)!; + expect(refreshed.nameOverride, 'Rescue One'); + expect(refreshed.displayName, 'Rescue One'); + expect(refreshed.advName, 'Updated Advertised Name'); + }); + + test('persists a local name override across reloads', () async { + provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One'); + await Future.delayed(Duration.zero); + + final reloadedProvider = ContactsProvider(); + await reloadedProvider.initializeEarly(); + + final reloaded = reloadedProvider.findContactByKey(publicKey)!; + expect(reloaded.nameOverride, 'Rescue One'); + expect(reloaded.displayName, 'Rescue One'); + }); + }); + + group('ContactsProvider route updates', () { + late ContactsProvider provider; + late Uint8List publicKey; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + publicKey = createPublicKey(64); + provider.addOrUpdateContact( + createContact(key: publicKey, type: ContactType.chat, name: 'Routey'), + ); + }); + + test('optimistically stores a multi-byte route locally', () { + final route = ContactRouteCodec.parse('AABB,CCDD'); + + provider.setContactRouteLocal( + publicKey, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + ); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.routeHasPath, isTrue); + expect(updated.routeHashSize, 2); + expect(updated.routeHopCount, 2); + expect(updated.routeCanonicalText, 'AABB,CCDD'); + }); + + test('stores inferred fallback gps when route is set locally', () { + final route = ContactRouteCodec.parse('AABB,CCDD'); + const fallback = LatLng(46.1001, 14.5002); + + provider.setContactRouteLocal( + publicKey, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + inferredFallbackLocation: fallback, + ); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.displayLocation, isNotNull); + expect(updated.displayLocation!.latitude, closeTo(46.1001, 0.000001)); + expect(updated.displayLocation!.longitude, closeTo(14.5002, 0.000001)); + expect(updated.advertHistory, isNotEmpty); + }); + + test('resetContactRouteLocal clears route state', () { + final route = ContactRouteCodec.parse('AA,BB,CC'); + provider.setContactRouteLocal( + publicKey, + signedEncodedPathLen: route.signedEncodedPathLen, + paddedPathBytes: route.paddedPathBytes, + ); + + provider.resetContactRouteLocal(publicKey); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.routeHasPath, isFalse); + expect(updated.routeSummary, 'Flood/Unknown'); + }); + + test('retains existing route when contact refresh omits path', () { + final retainedRoute = ContactRouteCodec.parse('AABB,CCDD'); + provider.setContactRouteLocal( + publicKey, + signedEncodedPathLen: retainedRoute.signedEncodedPathLen, + paddedPathBytes: retainedRoute.paddedPathBytes, + ); + + provider.addOrUpdateContact( + createContact( + key: publicKey, + type: ContactType.chat, + name: 'Routey', + ).copyWith(outPathLen: -1, outPath: Uint8List(0)), + ); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.routeHasPath, isTrue); + expect(updated.routeCanonicalText, 'AABB,CCDD'); + }); + + test('applies retained pending advert route when contact is resolved', () { + final pendingKey = createPublicKey(96); + final retainedRoute = ContactRouteCodec.parse('1122,3344'); + + provider.retainReceivedRoute( + pendingKey, + signedEncodedPathLen: retainedRoute.signedEncodedPathLen, + paddedPathBytes: retainedRoute.paddedPathBytes, + ); + provider.addPendingAdvert(pendingKey); + + provider.addOrUpdateContact( + createContact( + key: pendingKey, + type: ContactType.chat, + name: 'Pending Routey', + ).copyWith(outPathLen: -1, outPath: Uint8List(0)), + ); + + final updated = provider.findContactByKey(pendingKey)!; + expect(updated.routeHasPath, isTrue); + expect(updated.routeCanonicalText, '1122,3344'); + expect( + provider.pendingAdverts.where( + (advert) => advert.publicKeyHex == updated.publicKeyHex, + ), + isEmpty, + ); + }); + + test('infers a fallback location 100m from last-hop repeater', () { + final repeaterKey = Uint8List.fromList([ + 0xCC, + 0xDD, + 0x10, + 0x11, + 0x12, + 0x13, + ...List.generate(26, (index) => index + 20), + ]); + provider.addOrUpdateContact( + createContact( + key: repeaterKey, + type: ContactType.repeater, + name: 'Relay Alpha', + ), + ); + + final targetKey = createPublicKey(120); + final route = ContactRouteCodec.parse('AABB,CCDD'); + provider.addOrUpdateContact( + Contact( + publicKey: targetKey, + type: ContactType.chat, + flags: 0, + outPathLen: route.signedEncodedPathLen, + outPath: route.paddedPathBytes, + advName: 'No GPS', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + + final updated = provider.findContactByKey(targetKey)!; + final inferred = updated.displayLocation; + final repeater = provider.findContactByKey(repeaterKey)!; + final repeaterLocation = repeater.displayLocation; + + expect(inferred, isNotNull); + expect(repeaterLocation, isNotNull); + + final distanceMeters = Geolocator.distanceBetween( + repeaterLocation!.latitude, + repeaterLocation.longitude, + inferred!.latitude, + inferred.longitude, + ); + expect(distanceMeters, closeTo(100.0, 8.0)); + }); + + test( + 'does not infer a fallback location when the contact advertises one', + () { + final repeaterKey = Uint8List.fromList([ + 0xCC, + 0xDD, + 0x10, + 0x11, + 0x12, + 0x13, + ...List.generate(26, (index) => index + 20), + ]); + provider.addOrUpdateContact( + createContact( + key: repeaterKey, + type: ContactType.repeater, + name: 'Relay Alpha', + ), + ); + + final targetKey = createPublicKey(121); + final route = ContactRouteCodec.parse('AABB,CCDD'); + provider.addOrUpdateContact( + Contact( + publicKey: targetKey, + type: ContactType.chat, + flags: 0, + outPathLen: route.signedEncodedPathLen, + outPath: route.paddedPathBytes, + advName: 'Has Advert', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (45.1234 * 1e6).toInt(), + advLon: (13.8765 * 1e6).toInt(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + + final updated = provider.findContactByKey(targetKey)!; + expect(updated.displayLocation, isNotNull); + expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001)); + expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001)); + }, + ); + }); + + group('ContactsProvider.updateFastGps', () { + late ContactsProvider provider; + late Uint8List publicKey; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + publicKey = createPublicKey(50); + provider.addOrUpdateContact( + createContact(key: publicKey, type: ContactType.chat, name: 'Fast GPS'), + ); + }); + + test('updates gps while preserving other telemetry fields', () { + final batteryOnly = CayenneLppParser.createBatteryData(3.9); + provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly); + + provider.updateFastGps( + publicKey.sublist(0, 6), + const FastGpsPacket( + senderKey6: '323334353637', + latitude: 44.123456, + longitude: 13.654321, + speedKmh: 12, + ), + ); + + final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, isNotNull); + expect( + updated.telemetry!.gpsLocation!.latitude, + closeTo(44.123456, 0.000001), + ); + expect( + updated.telemetry!.gpsLocation!.longitude, + closeTo(13.654321, 0.000001), + ); + expect(updated.telemetry!.batteryMilliVolts, isNotNull); + expect(updated.advLat, equals((44.123456 * 1e6).round())); + expect(updated.advLon, equals((13.654321 * 1e6).round())); + // The beacon carries no timestamp; the receiver stamps its own RX time. + expect(updated.lastAdvert, closeTo(nowSeconds, 5)); + expect(updated.lastMod, closeTo(nowSeconds, 5)); + expect( + provider.estimatedLocationFor(updated.publicKeyHex), + isNotNull, + ); + expect( + provider.estimatedLocationFor(updated.publicKeyHex)!.latitude, + closeTo(44.123456, 0.000001), + ); + expect( + provider.estimatedLocationFor(updated.publicKeyHex)!.longitude, + closeTo(13.654321, 0.000001), + ); + }); + + test('ignores unknown sender prefix safely', () { + final before = provider.findContactByKey(publicKey)!; + provider.updateFastGps( + Uint8List.fromList([1, 2, 3, 4, 5, 6]), + const FastGpsPacket( + senderKey6: '010203040506', + latitude: 10, + longitude: 20, + speedKmh: 0, + ), + ); + final after = provider.findContactByKey(publicKey)!; + expect(after.advLat, equals(before.advLat)); + expect(after.advLon, equals(before.advLon)); + }); + }); + + group('ContactsProvider saved contact groups', () { + late ContactsProvider provider; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + }); + + test('adds and removes saved groups by filter', () async { + expect(provider.savedContactGroups, isEmpty); + + await provider.addSavedGroupForFilter('teamMembers', 'alpha'); + + expect(provider.savedContactGroups, hasLength(1)); + expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isTrue); + expect(provider.hasSavedGroupForFilter('teamMembers', 'ALPHA'), isTrue); + + await provider.removeSavedGroupForFilter('teamMembers', 'ALPHA'); + + expect(provider.savedContactGroups, isEmpty); + expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isFalse); + }); + + test('loads persisted saved groups during initialization', () async { + await provider.addSavedGroupForFilter('rooms', 'ops'); + + final restored = ContactsProvider(); + await restored.initializeEarly(); + + expect(restored.savedGroupsForSection('rooms'), hasLength(1)); + expect(restored.savedGroupsForSection('rooms').first.query, 'ops'); + expect(restored.savedGroupsForSection('rooms').first.label, 'ops'); + }); + + test('replaces persisted auto groups for a section', () async { + await provider.replaceAutoGroupsForSection('repeaters', [ + SavedContactGroup( + id: '${ContactsProvider.autoGroupIdPrefix}repeaters_al', + sectionKey: 'repeaters', + label: 'AL-', + query: 'AL-', + createdAt: DateTime(2026, 3, 10, 12), + matchPrefixes: const ['AL-'], + isAutoGroup: true, + ), + SavedContactGroup( + id: '${ContactsProvider.autoGroupIdPrefix}repeaters_others', + sectionKey: 'repeaters', + label: 'Others', + query: 'Others', + createdAt: DateTime(2026, 3, 10, 12), + matchPrefixes: const ['CR-', 'DE-'], + isAutoGroup: true, + ), + ]); + + final restored = ContactsProvider(); + await restored.initializeEarly(); + + expect(restored.savedGroupsForSection('repeaters'), hasLength(2)); + expect( + restored.savedGroupsForSection('repeaters').first.matchPrefixes, + isNotEmpty, + ); + expect( + restored.savedGroupsForSection('repeaters').first.isAutoGroup, + isTrue, + ); + }); + }); + + group('ContactsProvider device sync preparation', () { + late ContactsProvider provider; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + }); + + test( + 'keeps runtime contacts visible during sync without erasing persisted contacts or saved groups', + () async { + final key = createPublicKey(140); + final pendingKey = createPublicKey(180); + + provider.addOrUpdateContact( + createContact(key: key, type: ContactType.chat, name: 'Synced Later'), + ); + provider.addPendingAdvert(pendingKey); + await provider.addSavedGroupForFilter('teamMembers', 'alpha'); + + await provider.prepareForDeviceContactSync(); + + expect( + provider.chatContacts.map((contact) => contact.advName), + contains('Synced Later'), + ); + expect(provider.pendingAdverts, hasLength(1)); + expect(provider.savedGroupsForSection('teamMembers'), hasLength(1)); + + final restored = ContactsProvider(); + await restored.initializeEarly(); + + expect( + restored.chatContacts.map((contact) => contact.advName), + contains('Synced Later'), + ); + expect(restored.savedGroupsForSection('teamMembers'), hasLength(1)); + }, + ); + + test( + 'retains cached telemetry when sync re-adds a contact without telemetry', + () async { + final key = createPublicKey(141); + final contact = createContact( + key: key, + type: ContactType.sensor, + name: 'Sparse Sync Sensor', + ).copyWith( + telemetry: ContactTelemetry( + temperature: 18.5, + humidity: 64.0, + timestamp: DateTime(2026, 3, 22, 8, 30), + ), + ); + + provider.addOrUpdateContact(contact); + await provider.prepareForDeviceContactSync(); + + provider.addOrUpdateContact( + createContact( + key: key, + type: ContactType.sensor, + name: 'Sparse Sync Sensor', + ), + ); + + final restored = provider.findContactByKey(key); + expect(restored, isNotNull); + expect(restored!.telemetry, isNotNull); + expect(restored.telemetry!.temperature, closeTo(18.5, 0.01)); + expect(restored.telemetry!.humidity, closeTo(64.0, 0.01)); + }, + ); + + test('keeps self telemetry cached across reconnect for the same device', () async { + final selfKey = createPublicKey(200); + + await provider.initialize(devicePublicKey: selfKey); + provider.updateTelemetry( + selfKey.sublist(0, 6), + CayenneLppParser.createTemperatureData(23.5, channel: 1), + ); + + await provider.prepareForDeviceContactSync(devicePublicKey: selfKey); + + expect(provider.selfTelemetry, isNotNull); + expect(provider.selfTelemetry!.temperature, closeTo(23.5, 0.1)); + }); + }); + + group('ContactsProvider self telemetry', () { + test( + 'stores self telemetry without re-adding the device as a contact', + () async { + SharedPreferences.setMockInitialValues({}); + final provider = ContactsProvider(); + final selfKey = createPublicKey(200); + + await provider.initialize(devicePublicKey: selfKey); + provider.updateTelemetry( + selfKey.sublist(0, 6), + CayenneLppParser.createTemperatureData(23.5, channel: 1), + ); + + expect(provider.selfTelemetry, isNotNull); + expect(provider.selfTelemetry!.temperature, closeTo(23.5, 0.1)); + expect(provider.findContactByKey(selfKey), isNull); + expect( + provider.contacts.any( + (contact) => contact.publicKeyHex == publicKeyHex(selfKey), + ), + isFalse, + ); + }, + ); + }); + + group('ContactsProvider device-authoritative sync', () { + late ContactsProvider provider; + late Contact keptContact; + late Contact staleContact; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + keptContact = createContact( + key: createPublicKey(10), + type: ContactType.chat, + name: 'Still On Device', + ); + staleContact = createContact( + key: createPublicKey(40), + type: ContactType.chat, + name: 'Deleted On Device', + ); + provider.addOrUpdateContact(keptContact); + provider.addOrUpdateContact(staleContact); + await provider.prepareForDeviceContactSync(); + }); + + test('removes contacts the device no longer reports after a full sync', () { + // Device only re-sends one of the two contacts during sync. + provider.addOrUpdateContact(keptContact); + + provider.finalizeDeviceContactSync(deviceListComplete: true); + + expect(provider.findContactByKey(keptContact.publicKey), isNotNull); + expect(provider.findContactByKey(staleContact.publicKey), isNull); + }); + + test('keeps all contacts when the sync was partial', () { + provider.addOrUpdateContact(keptContact); + + provider.finalizeDeviceContactSync(deviceListComplete: false); + + expect(provider.findContactByKey(keptContact.publicKey), isNotNull); + expect(provider.findContactByKey(staleContact.publicKey), isNotNull); + }); + + test('never removes channel pseudo-contacts during contact sync', () { + final channelKey = Uint8List(32) + ..[0] = 0xFF + ..[1] = 3; + provider.addOrUpdateContact( + createContact( + key: channelKey, + type: ContactType.channel, + name: 'Team Channel', + ), + ); + // Re-prepare so the channel pseudo-contact is part of the snapshot. + provider.prepareForDeviceContactSync(); + provider.addOrUpdateContact(keptContact); + + provider.finalizeDeviceContactSync(deviceListComplete: true); + + expect(provider.findContactByKey(channelKey), isNotNull); + expect(provider.findContactByKey(staleContact.publicKey), isNull); + }); + }); + + test('stores raw telemetry hex in metadata for verification', () async { + SharedPreferences.setMockInitialValues({}); + final provider = ContactsProvider(); + final telemetryKey = createPublicKey(150); + final contact = createContact( + key: telemetryKey, + type: ContactType.chat, + name: 'Telemetry Node', + ); + provider.addOrUpdateContact(contact); + + final payload = Uint8List.fromList([0x01, 0x67, 0x00, 0xEB]); + provider.updateTelemetry(telemetryKey.sublist(0, 6), payload); + + final updated = provider.findContactByKey(telemetryKey)!; + expect(updated.telemetry?.extraSensorData?['__raw_lpp_hex'], '01 67 00 eb'); + }); +} + +String publicKeyHex(Uint8List publicKey) { + return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); +} diff --git a/test/providers/helpers/fragment_ack_wait_registry_test.dart b/test/providers/helpers/fragment_ack_wait_registry_test.dart new file mode 100644 index 0000000..38be098 --- /dev/null +++ b/test/providers/helpers/fragment_ack_wait_registry_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/helpers/fragment_ack_wait_registry.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FragmentAckWaitRegistry', () { + test('completes multiple waiters registered for the same key', () async { + final registry = FragmentAckWaitRegistry(); + + final first = registry.waitFor( + 'voice:1', + timeout: const Duration(milliseconds: 200), + ); + final second = registry.waitFor( + 'voice:1', + timeout: const Duration(milliseconds: 200), + ); + + expect(registry.complete('voice:1'), equals(2)); + expect(await first, isTrue); + expect(await second, isTrue); + }); + + test('times out and cleans up a waiter when no ack arrives', () async { + final registry = FragmentAckWaitRegistry(); + + final completed = await registry.waitFor( + 'voice:2', + timeout: const Duration(milliseconds: 20), + ); + + expect(completed, isFalse); + expect(registry.complete('voice:2'), equals(0)); + }); + }); +} diff --git a/test/providers/helpers/message_delivery_tracker_test.dart b/test/providers/helpers/message_delivery_tracker_test.dart new file mode 100644 index 0000000..1493e3f --- /dev/null +++ b/test/providers/helpers/message_delivery_tracker_test.dart @@ -0,0 +1,37 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/helpers/message_delivery_tracker.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MessageDeliveryTracker', () { + test('matches pending direct messages by contact', () { + final tracker = MessageDeliveryTracker(); + final alice = Uint8List.fromList(List.filled(32, 0xAA)); + final bob = Uint8List.fromList(List.filled(32, 0xBB)); + + tracker.trackPendingDirectMessage('alice-1', alice); + tracker.trackPendingDirectMessage('bob-1', bob); + tracker.trackPendingDirectMessage('alice-2', alice); + + expect(tracker.popPendingDirectMessageId(alice), 'alice-1'); + expect(tracker.popPendingDirectMessageId(bob), 'bob-1'); + expect(tracker.popPendingDirectMessageId(alice), 'alice-2'); + }); + + test('removeByMessageId clears pending queue state', () { + final tracker = MessageDeliveryTracker(); + final alice = Uint8List.fromList(List.filled(32, 0xAA)); + + tracker.trackPendingDirectMessage('alice-1', alice); + tracker.mapAckTagToMessageId(42, 'alice-1'); + + tracker.removeByMessageId('alice-1'); + + expect(tracker.getMessageIdForAck(42), isNull); + expect(tracker.popPendingDirectMessageId(alice), isNull); + }); + }); +} diff --git a/test/providers/helpers/ping_tracker_test.dart b/test/providers/helpers/ping_tracker_test.dart new file mode 100644 index 0000000..3891ae2 --- /dev/null +++ b/test/providers/helpers/ping_tracker_test.dart @@ -0,0 +1,27 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/helpers/ping_tracker.dart'; + +void main() { + Uint8List createPublicKey() => Uint8List.fromList( + List.generate(32, (index) => index + 1), + ); + + group('PingTracker', () { + test('completes pending ping when response uses public key prefix', () async { + final tracker = PingTracker(); + final publicKey = createPublicKey(); + + final pingFuture = tracker.trackPing( + publicKey: publicKey, + wasDirectAttempt: true, + ); + + tracker.markPingSuccessful(publicKey.sublist(0, 6)); + + await expectLater(pingFuture, completion(isTrue)); + expect(tracker.hasPendingPing(publicKey), isFalse); + }); + }); +} diff --git a/test/providers/helpers/raw_session_retransmit_test.dart b/test/providers/helpers/raw_session_retransmit_test.dart new file mode 100644 index 0000000..d4e1390 --- /dev/null +++ b/test/providers/helpers/raw_session_retransmit_test.dart @@ -0,0 +1,161 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/providers/helpers/raw_session_retransmit.dart'; + +class _Fragment { + final int index; + final Uint8List payload; + + _Fragment(this.index, this.payload); +} + +Contact _buildContact({required int outPathLen}) { + return Contact( + publicKey: Uint8List.fromList(List.generate(32, (i) => i)), + type: ContactType.chat, + flags: 0, + outPathLen: outPathLen, + outPath: Uint8List.fromList(List.generate(8, (i) => i + 1)), + advName: 'Requester', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('serveCachedSessionFragments', () { + test('returns false when sender callback is missing', () async { + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: _buildContact(outPathLen: 1), + fragments: [ + _Fragment(0, Uint8List.fromList([1])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: null, + ); + + expect(ok, isFalse); + }); + + test('returns false when requester has no learned path', () async { + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: _buildContact(outPathLen: -1), + fragments: [ + _Fragment(0, Uint8List.fromList([1])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: + ({ + required contactPath, + required contactPathLen, + required payload, + }) async {}, + ); + + expect(ok, isFalse); + }); + + test('returns false when requester path payload is empty', () async { + final requester = Contact( + publicKey: Uint8List.fromList(List.generate(32, (i) => i)), + type: ContactType.chat, + flags: 0, + outPathLen: 1, + outPath: Uint8List(0), + advName: 'Requester', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: requester, + fragments: [ + _Fragment(0, Uint8List.fromList([1])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: + ({ + required contactPath, + required contactPathLen, + required payload, + }) async {}, + ); + + expect(ok, isFalse); + }); + + test('sends only requested indices', () async { + final sent = []; + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: _buildContact(outPathLen: 1), + fragments: [ + _Fragment(0, Uint8List.fromList([10])), + _Fragment(1, Uint8List.fromList([20])), + _Fragment(2, Uint8List.fromList([30])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: + ({ + required contactPath, + required contactPathLen, + required payload, + }) async { + sent.add(payload); + }, + requestedIndices: {1, 2}, + ); + + expect(ok, isTrue); + expect(sent.length, equals(2)); + expect(sent[0], equals(Uint8List.fromList([20]))); + expect(sent[1], equals(Uint8List.fromList([30]))); + }); + + test('fails when no requested index matches cached fragments', () async { + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: _buildContact(outPathLen: 1), + fragments: [ + _Fragment(0, Uint8List.fromList([1])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: + ({ + required contactPath, + required contactPathLen, + required payload, + }) async {}, + requestedIndices: {99}, + ); + + expect(ok, isFalse); + }); + }); +} diff --git a/test/providers/helpers/session_metadata_restore_test.dart b/test/providers/helpers/session_metadata_restore_test.dart new file mode 100644 index 0000000..891ab48 --- /dev/null +++ b/test/providers/helpers/session_metadata_restore_test.dart @@ -0,0 +1,145 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/providers/helpers/session_metadata_restore.dart'; +import 'package:meshcore_sar_app/utils/image_message_parser.dart'; +import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; + +void main() { + group('restoreSessionMetadataFromMessages', () { + test( + 'restores voice and image session senders from persisted envelopes', + () { + final voiceEnvelope = VoiceEnvelope( + sessionId: '00112233', + mode: VoicePacketMode.mode1200, + total: 4, + durationMs: 4000, + ); + final imageEnvelope = ImageEnvelope( + sessionId: '195cb2fb', + format: ImageFormat.avif, + total: 7, + width: 118, + height: 256, + sizeBytes: 1069, + ); + + final restored = restoreSessionMetadataFromMessages([ + Message( + id: 'plain', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1, + text: 'plain text', + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sent, + ), + Message( + id: 'voice', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 2, + text: voiceEnvelope.encodeText(), + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList( + [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff], + ), + deliveryStatus: MessageDeliveryStatus.sent, + ), + Message( + id: 'image', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 3, + text: imageEnvelope.encode(), + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList( + [0xfe, 0x8b, 0x30, 0xee, 0x05, 0xfc], + ), + deliveryStatus: MessageDeliveryStatus.sent, + ), + ]); + + expect( + restored.voiceSenderKeyBySession, + equals({'00112233': 'aabbccddeeff'}), + ); + expect( + restored.imageSenderKeyBySession, + equals({'195cb2fb': 'fe8b30ee05fc'}), + ); + expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'})); + expect( + restored.imageEnvelopeBySession['195cb2fb']?.sessionId, + equals('195cb2fb'), + ); + }, + ); + + test('keeps latest envelope when a session appears multiple times', () { + final first = ImageEnvelope( + sessionId: '195cb2fb', + format: ImageFormat.avif, + total: 7, + width: 100, + height: 100, + sizeBytes: 900, + ); + final second = ImageEnvelope( + sessionId: '195cb2fb', + format: ImageFormat.jpeg, + total: 8, + width: 118, + height: 256, + sizeBytes: 1069, + ); + + final restored = restoreSessionMetadataFromMessages([ + Message( + id: 'first', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1, + text: first.encode(), + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + deliveryStatus: MessageDeliveryStatus.sent, + ), + Message( + id: 'second', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 2, + text: second.encode(), + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList( + [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff], + ), + deliveryStatus: MessageDeliveryStatus.sent, + ), + ]); + + expect(restored.imageEnvelopeBySession.length, equals(1)); + expect( + restored.imageSenderKeyBySession['195cb2fb'], + equals('aabbccddeeff'), + ); + expect( + restored.imageEnvelopeBySession['195cb2fb']?.format, + equals(ImageFormat.jpeg), + ); + }); + }); +} diff --git a/test/providers/image_provider_cancel_test.dart b/test/providers/image_provider_cancel_test.dart new file mode 100644 index 0000000..fe68ad9 --- /dev/null +++ b/test/providers/image_provider_cancel_test.dart @@ -0,0 +1,50 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart'; +import 'package:meshcore_sar_app/utils/image_message_parser.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + group('ImageProvider cancel receive', () { + test('ignores incoming fragments after cancel until resumed', () { + final provider = ImageProvider(); + const sessionId = '01020304'; + const envelope = ImageEnvelope( + sessionId: sessionId, + format: ImageFormat.avif, + total: 2, + width: 32, + height: 32, + sizeBytes: 4, + ); + final fragment = ImagePacket( + sessionId: sessionId, + format: ImageFormat.avif, + index: 0, + total: 2, + data: Uint8List.fromList([1, 2]), + ); + + provider.registerEnvelope(envelope); + provider.cancelIncomingSession(sessionId); + + expect(provider.isReceiveCanceled(sessionId), isTrue); + expect(provider.session(sessionId), isNull); + + provider.addFragment(fragment, width: 32, height: 32); + + expect(provider.session(sessionId), isNull); + + provider.resumeIncomingSession(sessionId); + provider.registerEnvelope(envelope); + provider.addFragment(fragment, width: 32, height: 32); + + expect(provider.isReceiveCanceled(sessionId), isFalse); + expect(provider.session(sessionId)?.receivedCount, equals(1)); + }); + }); +} diff --git a/test/providers/image_provider_swarm_test.dart b/test/providers/image_provider_swarm_test.dart new file mode 100644 index 0000000..2e2647b --- /dev/null +++ b/test/providers/image_provider_swarm_test.dart @@ -0,0 +1,90 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart'; +import 'package:meshcore_sar_app/utils/image_message_parser.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Contact _buildRequester() { + return Contact( + publicKey: Uint8List.fromList(List.generate(32, (i) => i)), + type: ContactType.chat, + flags: 0, + outPathLen: 1, + outPath: Uint8List.fromList([1, 2, 3, 4]), + advName: 'Requester', + lastAdvert: 1700000000, + advLat: 0, + advLon: 0, + lastMod: 1700000000, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ImageProvider swarm serving', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('serves requested fragments from received session cache', () async { + final provider = ImageProvider(); + provider.registerEnvelope( + const ImageEnvelope( + sessionId: 'deadbeef', + format: ImageFormat.avif, + total: 3, + width: 64, + height: 64, + sizeBytes: 300, + ), + ); + + provider.addFragment( + ImagePacket( + sessionId: 'deadbeef', + format: ImageFormat.avif, + index: 0, + total: 3, + data: Uint8List.fromList([1, 2]), + ), + width: 64, + height: 64, + ); + provider.addFragment( + ImagePacket( + sessionId: 'deadbeef', + format: ImageFormat.avif, + index: 2, + total: 3, + data: Uint8List.fromList([7, 8]), + ), + width: 64, + height: 64, + ); + + final sent = []; + provider.sendRawPacketCallback = + ({ + required contactPath, + required contactPathLen, + required payload, + }) async { + sent.add(payload); + }; + + final ok = await provider.serveSessionTo( + sessionId: 'deadbeef', + requester: _buildRequester(), + requestedIndices: {2}, + ); + + expect(ok, isTrue); + expect(provider.availableFragmentIndices('deadbeef'), [0, 2]); + expect(sent, hasLength(1)); + expect(ImagePacket.tryParseBinary(sent.single)?.index, 2); + }); + }); +} diff --git a/test/providers/media_provider_profile_scope_test.dart b/test/providers/media_provider_profile_scope_test.dart new file mode 100644 index 0000000..bed6df2 --- /dev/null +++ b/test/providers/media_provider_profile_scope_test.dart @@ -0,0 +1,134 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart'; +import 'package:meshcore_sar_app/providers/voice_provider.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; +import 'package:meshcore_sar_app/services/voice_codec_service.dart'; +import 'package:meshcore_sar_app/services/voice_player_service.dart'; +import 'package:meshcore_sar_app/utils/image_message_parser.dart'; +import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); + }); + + test('VoiceProvider reloads profile-scoped sessions', () async { + final player = _FakeVoicePlayerService(); + final provider = VoiceProvider(codec: VoiceCodecService(), player: player); + addTearDown(provider.dispose); + + await provider.reloadProfileScopedState(); + provider.registerEnvelope( + const VoiceEnvelope( + sessionId: 'a1b2c3d4', + mode: VoicePacketMode.mode1200, + total: 2, + durationMs: 1600, + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'beta', + ); + await provider.reloadProfileScopedState(); + expect(provider.session('a1b2c3d4'), isNull); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); + await provider.reloadProfileScopedState(); + expect(provider.session('a1b2c3d4'), isNotNull); + }); + + test('ImageProvider reloads profile-scoped sessions', () async { + final provider = ImageProvider(); + + await provider.reloadProfileScopedState(); + provider.registerEnvelope( + const ImageEnvelope( + sessionId: 'a1b2c3d4', + format: ImageFormat.avif, + total: 2, + width: 32, + height: 32, + sizeBytes: 8, + ), + ); + provider.addFragment( + ImagePacket( + sessionId: 'a1b2c3d4', + format: ImageFormat.avif, + index: 0, + total: 2, + data: Uint8List.fromList([1, 2, 3, 4]), + ), + width: 32, + height: 32, + ); + await Future.delayed(const Duration(milliseconds: 50)); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'beta', + ); + await provider.reloadProfileScopedState(); + expect(provider.session('a1b2c3d4'), isNull); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); + await provider.reloadProfileScopedState(); + expect(provider.session('a1b2c3d4'), isNotNull); + }); +} + +class _FakeVoicePlayerService implements VoicePlayerService { + final StreamController _events = StreamController.broadcast(); + bool _isPlaying = false; + + @override + bool get isPlaying => _isPlaying; + + @override + Duration get position => Duration.zero; + + @override + Duration get duration => Duration.zero; + + @override + Stream get events => _events.stream; + + @override + Future play(Int16List pcmSamples, {required int sampleRateHz}) async { + _isPlaying = true; + _events.add(null); + } + + @override + Future stop() async { + _isPlaying = false; + _events.add(null); + } + + @override + void dispose() { + _events.close(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart new file mode 100644 index 0000000..0b6b189 --- /dev/null +++ b/test/providers/messages_provider_retransmission_test.dart @@ -0,0 +1,1096 @@ +import 'dart:typed_data'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/models/message_reception_details.dart'; +import 'package:meshcore_sar_app/models/path_selection.dart'; +import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/services/message_storage_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Contact _buildContact({ + int outPathLen = 1, + List outPath = const [1, 2, 3, 4], +}) { + return Contact( + publicKey: Uint8List.fromList(List.generate(32, (i) => i)), + type: ContactType.chat, + flags: 0, + outPathLen: outPathLen, + outPath: Uint8List.fromList(outPath), + advName: 'Teammate', + lastAdvert: 1700000000, + advLat: 0, + advLon: 0, + lastMod: 1700000000, + ); +} + +Message _buildDirectMessage(String id) { + return Message( + id: id, + messageType: MessageType.contact, + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'hello', + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: Uint8List.fromList(List.generate(32, (i) => i)), + ); +} + +Message _buildSentChannelMessage({ + required String id, + required int senderTimestamp, + String text = 'broadcast', + int channelIdx = 0, +}) { + return Message( + id: id, + messageType: MessageType.channel, + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + channelIdx: channelIdx, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: senderTimestamp, + text: text, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + ); +} + +Message _buildReceivedChannelReplay({ + required String id, + required int senderTimestamp, + String text = 'broadcast', + int channelIdx = 0, + String senderName = 'Radio Alpha', +}) { + return Message( + id: id, + messageType: MessageType.channel, + channelIdx: channelIdx, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: senderTimestamp, + text: text, + senderName: senderName, + receivedAt: DateTime.now(), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('MessagesProvider retransmission', () { + test('direct messages become sent before delivery ACK arrives', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m1'), + contact: _buildContact(), + ); + + provider.markMessageSent('m1', 77, 250); + + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sent, + ); + expect(provider.messages.single.expectedAckTag, 77); + + provider.markMessageDelivered(77, 180); + + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.delivered, + ); + expect(provider.messages.single.roundTripTimeMs, 180); + }); + + test( + 'direct messages stay sent after device accept until confirm arrives', + () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m1b'), + contact: _buildContact(), + ); + + provider.markMessageSent('m1b', 78, 250); + + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sent, + ); + expect(provider.messages.single.expectedAckTag, 78); + expect(provider.messages.single.roundTripTimeMs, isNull); + expect(provider.messages.single.deliveredAt, isNull); + }, + ); + + test('fallback sent state can later upgrade to ACK-tracked delivery', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m1c'), + contact: _buildContact(), + ); + + provider.markMessageSent('m1c', 0, 0); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sent, + ); + expect(provider.messages.single.expectedAckTag, isNull); + + provider.markMessageSent('m1c', 79, 250); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sent, + ); + expect(provider.messages.single.expectedAckTag, 79); + + provider.markMessageDelivered(79, 190); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.delivered, + ); + expect(provider.messages.single.roundTripTimeMs, 190); + }); + + test('delivered flood message upgrades to learned direct route from ACK path', () { + final provider = MessagesProvider(); + final contactWithoutRoute = _buildContact(outPathLen: -1, outPath: []); + provider.addSentMessage( + _buildDirectMessage('m1d'), + contact: contactWithoutRoute, + ); + provider.updateMessageRouteSelection( + 'm1d', + PathSelection.flood(), + routerFallbackAttempted: false, + ); + + provider.markMessageSent('m1d', 80, 250); + provider.markMessageDelivered(80, 200); + provider.queueDeliveredMessageRouteRefresh('m1d', contactWithoutRoute); + + final applied = provider.applyDeliveredMessageRouteFromContact( + _buildContact(outPathLen: 2, outPath: const [0xAA, 0xBB]), + ); + + expect(applied, isTrue); + expect(provider.messages.single.deliveryStatus, MessageDeliveryStatus.delivered); + expect(provider.messages.single.usedFloodFallback, isFalse); + expect(provider.messages.single.pathLen, 2); + expect( + provider.getMessageRouteMetadata('m1d')?.mode, + PathSelectionMode.directCurrent, + ); + expect( + provider.getMessageRouteMetadata('m1d')?.canonicalPath, + 'AA,BB', + ); + expect(provider.getMessageRouteMetadata('m1d')?.hopCount, 2); + }); + + test('channel messages are marked sent immediately', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage(id: 'c1', senderTimestamp: 1700000000), + ); + + provider.markMessageSent('c1', 0, 0); + + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sent, + ); + }); + + test('channel warning appears when no echo arrives in time', () { + fakeAsync((async) { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildSentChannelMessage(id: 'c-warn', senderTimestamp: 1700000000), + ); + + provider.markMessageSent('c-warn', 0, 0); + expect(provider.hasChannelSendWarning('c-warn'), isFalse); + + async.elapse(const Duration(seconds: 12)); + async.flushMicrotasks(); + + expect(provider.hasChannelSendWarning('c-warn'), isTrue); + }); + }); + + test('channel warning clears when echo arrives before timeout', () { + fakeAsync((async) { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-warn-echo', + senderTimestamp: 1700000000, + ), + ); + + provider.markMessageSent('c-warn-echo', 0, 0); + async.elapse(const Duration(seconds: 6)); + provider.handleMessageEcho('c-warn-echo', 1, 4, -90); + async.elapse(const Duration(seconds: 6)); + async.flushMicrotasks(); + + expect(provider.hasChannelSendWarning('c-warn-echo'), isFalse); + }); + }); + + test('repeated echo callbacks accumulate when radio reports one each time', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-echo-callbacks', + senderTimestamp: 1700000002, + ), + ); + provider.markMessageSent('c-echo-callbacks', 0, 0); + + provider.handleMessageEcho( + 'c-echo-callbacks', + 1, + 4, + -90, + pathBytes: Uint8List.fromList([0xAA]), + ); + provider.handleMessageEcho( + 'c-echo-callbacks', + 1, + 5, + -89, + pathBytes: Uint8List.fromList([0xAA, 0xBB]), + ); + provider.handleMessageEcho( + 'c-echo-callbacks', + 1, + 6, + -88, + pathBytes: Uint8List.fromList([0xAA, 0xBB, 0xCC]), + ); + + expect(provider.messages.single.echoCount, equals(3)); + expect(provider.messages.single.lastEchoSnrRaw, equals(6)); + expect(provider.messages.single.lastEchoRssiDbm, equals(-88)); + expect( + provider.getMessageReceptionDetails('c-echo-callbacks')?.pathBytes, + [0xAA, 0xBB, 0xCC], + ); + }); + + test('channel warning clears when replay arrives after send', () { + fakeAsync((async) { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-warn-replay', + senderTimestamp: 1700000100, + ), + ); + + provider.markMessageSent('c-warn-replay', 0, 0); + async.elapse(const Duration(seconds: 12)); + async.flushMicrotasks(); + expect(provider.hasChannelSendWarning('c-warn-replay'), isTrue); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-warn-replay-incoming', + senderTimestamp: 1700000101, + senderName: 'dz0ny (SI)', + ), + ); + + expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse); + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('c-warn-replay')); + }); + }); + + test('channel replay merges into sent message for self sender within repeat window', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage(id: 'c-echo', senderTimestamp: 1700000100), + ); + provider.markMessageSent('c-echo', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-echo-incoming', + senderTimestamp: 1700000104, + senderName: 'dz0ny (SI)', + ), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('c-echo')); + expect(provider.messages.single.echoCount, equals(1)); + expect(provider.messages.single.pathLen, equals(1)); + }); + + test('channel replay count reflects how many times a sent message was heard', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-repeat-count', + senderTimestamp: 1700000110, + ), + ); + provider.markMessageSent('c-repeat-count', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-repeat-count-1', + senderTimestamp: 1700000111, + senderName: 'dz0ny (SI)', + ), + ); + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-repeat-count-2', + senderTimestamp: 1700000112, + senderName: 'dz0ny (SI)', + ), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.echoCount, equals(2)); + expect(provider.getMessageReceptionDetails('c-repeat-count')?.receivedCopies, equals(3)); + }); + + test('channel replay keeps flood mode hop count untouched', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-flood-path', + senderTimestamp: 1700000120, + ), + ); + provider.updateMessageRouteSelection( + 'c-flood-path', + PathSelection.flood(), + routerFallbackAttempted: false, + ); + provider.markMessageSent('c-flood-path', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-flood-path-incoming', + senderTimestamp: 1700000121, + senderName: 'dz0ny (SI)', + ).copyWith(pathBytes: Uint8List.fromList([0xAA, 0xBB])), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.echoCount, equals(1)); + expect(provider.messages.single.pathLen, equals(0)); + expect(provider.messages.single.pathBytes, isEmpty); + expect( + provider.getMessageRouteMetadata('c-flood-path')?.mode, + PathSelectionMode.flood, + ); + }); + + test('channel replay preserves sent direct path bytes', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-direct-path', + senderTimestamp: 1700000130, + ), + ); + provider.updateMessageRouteSelection( + 'c-direct-path', + PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList([0x01, 0x02]), + hopCount: 2, + hashSize: 1, + ), + routerFallbackAttempted: false, + ); + provider.markMessageSent('c-direct-path', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-direct-path-incoming', + senderTimestamp: 1700000131, + senderName: 'dz0ny (SI)', + ).copyWith(pathBytes: Uint8List.fromList([0xAA, 0xBB])), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.echoCount, equals(1)); + expect(provider.messages.single.pathLen, equals(2)); + expect(provider.messages.single.pathBytes, [0x01, 0x02]); + }); + + test( + 'channel replay is not deduped for different sender with same text', + () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage( + id: 'c-no-echo', + senderTimestamp: 1700000200, + ), + ); + provider.markMessageSent('c-no-echo', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-no-echo-incoming', + senderTimestamp: 1700000201, + senderName: 'Radio Alpha', + ), + ); + + expect(provider.messages, hasLength(2)); + }, + ); + + test('channel replay is not deduped outside repeat window', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage(id: 'c-late', senderTimestamp: 1700000300), + ); + provider.markMessageSent('c-late', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-late-incoming', + senderTimestamp: 1700000331, + senderName: 'dz0ny (SI)', + ), + ); + + expect(provider.messages, hasLength(2)); + }); + + test('channel replay merges using lazily resolved self name', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildSentChannelMessage(id: 'c-lazy', senderTimestamp: 1700000400), + ); + provider.markMessageSent('c-lazy', 0, 0); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-lazy-incoming', + senderTimestamp: 1700000406, + senderName: 'dz0ny (SI)', + ), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('c-lazy')); + }); + + test('channel replay merges for meshcore-prefixed self sender name', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'MeshCore-dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage(id: 'c-prefix', senderTimestamp: 1700000500), + ); + provider.markMessageSent('c-prefix', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'c-prefix-incoming', + senderTimestamp: 1700000500, + senderName: 'dz0ny (SI)', + ), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('c-prefix')); + }); + + test('duplicate incoming message increments received copy count', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([7, 7, 4, 1, 7, 2]); + + provider.addMessage( + Message( + id: 'copy-1', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000600, + text: 'same payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + receptionDetailsSnapshot: MessageReceptionDetails( + capturedAt: DateTime.now(), + ), + ); + provider.addMessage( + Message( + id: 'copy-2', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000601, + text: 'same payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + receptionDetailsSnapshot: MessageReceptionDetails( + capturedAt: DateTime.now(), + ), + ); + + expect(provider.messages, hasLength(1)); + expect( + provider.getMessageReceptionDetails('copy-1')?.receivedCopies, + equals(2), + ); + }); + + test('incoming contact messages dedupe by sender handle and text', () { + final provider = MessagesProvider(); + + provider.addMessage( + Message( + id: 'handle-1', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000800, + text: 'same payload', + senderName: 'Radio Alpha', + receivedAt: DateTime.now(), + ), + ); + provider.addMessage( + Message( + id: 'handle-2', + messageType: MessageType.contact, + pathLen: 2, + textType: MessageTextType.plain, + senderTimestamp: 1700000810, + text: 'same payload', + senderName: 'Radio Alpha', + receivedAt: DateTime.now(), + ), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('handle-1')); + }); + + test('channel duplicates dedupe across recent interleaved channel traffic', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([9, 8, 7, 6, 5, 4]); + + provider.addMessage( + Message( + id: 'channel-first', + messageType: MessageType.channel, + channelIdx: 2, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000820, + text: 'same payload', + senderName: 'Radio Alpha', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ); + provider.addMessage( + Message( + id: 'channel-middle', + messageType: MessageType.channel, + channelIdx: 2, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000825, + text: 'different payload', + senderName: 'Radio Bravo', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + ), + ); + provider.addMessage( + Message( + id: 'channel-repeat', + messageType: MessageType.channel, + channelIdx: 2, + pathLen: 2, + textType: MessageTextType.plain, + senderTimestamp: 1700000830, + text: 'same payload', + senderName: 'Radio Alpha', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ); + + expect(provider.messages, hasLength(2)); + expect(provider.messages.first.id, equals('channel-first')); + expect(provider.messages.last.id, equals('channel-middle')); + }); + + test('adjacent channel duplicates still dedupe within 5 seconds', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([4, 5, 6, 7, 8, 9]); + final baseTime = DateTime.fromMillisecondsSinceEpoch(1700000840000); + + provider.addMessage( + Message( + id: 'adjacent-1', + messageType: MessageType.channel, + channelIdx: 3, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000840, + text: 'same payload', + senderName: 'Radio Charlie', + receivedAt: baseTime, + senderPublicKeyPrefix: sender, + ), + ); + provider.addMessage( + Message( + id: 'adjacent-2', + messageType: MessageType.channel, + channelIdx: 3, + pathLen: 2, + textType: MessageTextType.plain, + senderTimestamp: 1700000845, + text: 'same payload', + senderName: 'Radio Charlie', + receivedAt: baseTime.add(const Duration(seconds: 4)), + senderPublicKeyPrefix: sender, + ), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('adjacent-1')); + }); + + test('channel duplicates still dedupe when traffic lands between them', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([4, 5, 6, 7, 8, 9]); + final baseTime = DateTime.fromMillisecondsSinceEpoch(1700000840000); + + provider.addMessage( + Message( + id: 'interleaved-1', + messageType: MessageType.channel, + channelIdx: 3, + pathLen: 4, + textType: MessageTextType.plain, + senderTimestamp: 1700000840, + text: 'Tudi vsm', + senderName: 'Tady (SI)', + receivedAt: baseTime, + senderPublicKeyPrefix: sender, + ), + ); + provider.addMessage( + Message( + id: 'interleaved-other', + messageType: MessageType.channel, + channelIdx: 3, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000841, + text: 'Different payload', + senderName: 'MHQ-1 [SI]', + receivedAt: baseTime.add(const Duration(seconds: 1)), + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + ), + ); + provider.addMessage( + Message( + id: 'interleaved-2', + messageType: MessageType.channel, + channelIdx: 3, + pathLen: 4, + textType: MessageTextType.plain, + senderTimestamp: 1700000842, + text: 'Tudi vsm', + senderName: 'Tady (SI)', + receivedAt: baseTime.add(const Duration(seconds: 2)), + senderPublicKeyPrefix: sender, + ), + ); + + expect(provider.messages, hasLength(2)); + expect(provider.messages.first.id, equals('interleaved-1')); + expect( + provider.getMessageReceptionDetails('interleaved-1')?.receivedCopies, + equals(2), + ); + }); + + test('channel duplicates outside 5 second window are kept', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([6, 7, 8, 9, 0, 1]); + final baseTime = DateTime.fromMillisecondsSinceEpoch(1700000850000); + + provider.addMessage( + Message( + id: 'window-1', + messageType: MessageType.channel, + channelIdx: 4, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000850, + text: 'same payload', + senderName: 'Radio Delta', + receivedAt: baseTime, + senderPublicKeyPrefix: sender, + ), + ); + provider.addMessage( + Message( + id: 'window-2', + messageType: MessageType.channel, + channelIdx: 4, + pathLen: 2, + textType: MessageTextType.plain, + senderTimestamp: 1700000855, + text: 'same payload', + senderName: 'Radio Delta', + receivedAt: baseTime.add(const Duration(seconds: 6)), + senderPublicKeyPrefix: sender, + ), + ); + + expect(provider.messages, hasLength(2)); + expect(provider.messages.last.id, equals('window-2')); + }); + + test('display list collapses stored duplicates and sums copy counts', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]); + + provider.addMessages([ + Message( + id: 'dup-a', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000705, + text: 'same payload', + senderName: 'Klemen', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + Message( + id: 'dup-b', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 2, + textType: MessageTextType.plain, + senderTimestamp: 1700000700, + text: 'same payload', + senderName: 'Klemen', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ]); + + final display = provider.buildDisplayMessages( + provider.getRecentMessages(), + ); + + expect(display, hasLength(1)); + expect(display.single.message.id, equals('dup-a')); + expect(display.single.occurrenceCount, equals(2)); + }); + + test('display list keeps a single sent message for self replay', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage(id: 'display-sent', senderTimestamp: 1700000900), + ); + provider.markMessageSent('display-sent', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'display-received', + senderTimestamp: 1700000901, + senderName: 'dz0ny (SI)', + ), + ); + + final display = provider.buildDisplayMessages( + provider.getRecentMessages(), + ); + + expect(display, hasLength(1)); + expect(display.single.message.id, equals('display-sent')); + }); + + test('initialize collapses persisted duplicates from storage', () async { + final storage = MessageStorageService(); + final sender = Uint8List.fromList([5, 5, 5, 5, 5, 5]); + final firstReceivedAt = DateTime.fromMillisecondsSinceEpoch(1700001000000); + + await storage.saveMessages( + [ + Message( + id: 'persist-a', + messageType: MessageType.channel, + channelIdx: 3, + pathLen: 4, + textType: MessageTextType.plain, + senderTimestamp: 1700001000, + text: 'Tudi vsm', + senderName: 'Tady (SI)', + receivedAt: firstReceivedAt, + senderPublicKeyPrefix: sender, + ), + Message( + id: 'persist-b', + messageType: MessageType.channel, + channelIdx: 3, + pathLen: 4, + textType: MessageTextType.plain, + senderTimestamp: 1700001001, + text: 'Tudi vsm', + senderName: 'Tady (SI)', + receivedAt: firstReceivedAt.add(const Duration(seconds: 2)), + senderPublicKeyPrefix: sender, + ), + ], + messageReceptionDetails: { + 'persist-a': MessageReceptionDetails(capturedAt: firstReceivedAt), + 'persist-b': MessageReceptionDetails( + capturedAt: firstReceivedAt.add(const Duration(seconds: 2)), + ), + }, + ); + + final provider = MessagesProvider(); + await provider.initialize(); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('persist-a')); + expect( + provider.getMessageReceptionDetails('persist-a')?.receivedCopies, + equals(2), + ); + }); + + test('missing ACK schedules a delayed retransmission', () { + fakeAsync((async) { + final provider = MessagesProvider(); + var retryCalls = 0; + provider.sendMessageCallback = + ({ + required contactPublicKey, + required text, + required messageId, + required contact, + retryAttempt = 0, + }) async { + retryCalls += 1; + return true; + }; + + provider.addSentMessage( + _buildDirectMessage('m2'), + contact: _buildContact(), + ); + provider.markMessageSent('m2', 88, 10); + + final timeoutMs = provider.messages.single.suggestedTimeoutMs!; + + async.elapse(Duration(milliseconds: timeoutMs + 1)); + async.flushMicrotasks(); + + expect(provider.messages.single.retryAttempt, 1); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sending, + ); + expect(retryCalls, 0); + + async.elapse(const Duration(milliseconds: 998)); + async.flushMicrotasks(); + expect(retryCalls, 0); + + async.elapse(const Duration(milliseconds: 1)); + async.flushMicrotasks(); + + expect(retryCalls, 1); + }); + }); + + test('uses calculated timeout when radio timeout is missing', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m3'), + contact: _buildContact(), + ); + + provider.markMessageSent('m3', 99, 0); + + expect(provider.messages.single.suggestedTimeoutMs, isNotNull); + expect( + provider.messages.single.suggestedTimeoutMs!, + greaterThanOrEqualTo(4000), + ); + }); + + test('older retry ack still marks message delivered', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m4'), + contact: _buildContact(), + ); + + provider.markMessageSent('m4', 111, 10); + provider.markMessageSent('m4', 112, 10); + provider.markMessageDelivered(111, 220); + + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.delivered, + ); + expect(provider.messages.single.roundTripTimeMs, 220); + }); + + test('manual retry reuses the same message record', () { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage('m4b'), + contact: _buildContact(), + ); + + provider.markMessageSent('m4b', 113, 10); + provider.markMessageDelivered(113, 220); + + final prepared = provider.prepareMessageForRetry('m4b'); + + expect(prepared, isTrue); + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, 'm4b'); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sending, + ); + expect(provider.messages.single.expectedAckTag, isNull); + expect(provider.messages.single.roundTripTimeMs, isNull); + expect(provider.messages.single.deliveredAt, isNull); + expect(provider.messages.single.retryAttempt, 0); + }); + + test( + 'final router fallback runs after all normal retries are exhausted', + () async { + final provider = MessagesProvider(); + final fallbackCalls = []; + provider.onFinalRouterFallbackCallback = + ({required messageId, required contact, required message}) async { + fallbackCalls.add(messageId); + return true; + }; + + provider.addSentMessage( + _buildDirectMessage( + 'm5', + ).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts), + contact: _buildContact(), + ); + + provider.markMessageFailed('m5'); + await Future.delayed(Duration.zero); + + expect(fallbackCalls, ['m5']); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.sending, + ); + }, + ); + + test('final router fallback is not retried twice', () async { + final provider = MessagesProvider(); + provider.addSentMessage( + _buildDirectMessage( + 'm6', + ).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts), + contact: _buildContact(), + ); + provider.updateMessageRouteSelection( + 'm6', + PathSelection.flood(), + routerFallbackAttempted: true, + ); + + provider.markMessageFailed('m6'); + await Future.delayed(Duration.zero); + + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.failed, + ); + }); + + test( + 'final permanent failure callback runs after router fallback failure', + () async { + final provider = MessagesProvider(); + final failedMessageIds = []; + provider.onFinalDirectMessageFailureCallback = + ({required messageId, required contact, required message}) async { + failedMessageIds.add(messageId); + }; + + provider.addSentMessage( + _buildDirectMessage( + 'm7', + ).copyWith(retryAttempt: MessageRetryManager.maxRetryAttempts), + contact: _buildContact(), + ); + provider.updateMessageRouteSelection( + 'm7', + PathSelection.flood(), + routerFallbackAttempted: true, + ); + + provider.markMessageFailed('m7'); + await Future.delayed(Duration.zero); + + expect(failedMessageIds, ['m7']); + expect( + provider.messages.single.deliveryStatus, + MessageDeliveryStatus.failed, + ); + }, + ); + }); +} diff --git a/test/providers/messages_provider_voice_test.dart b/test/providers/messages_provider_voice_test.dart new file mode 100644 index 0000000..a518b45 --- /dev/null +++ b/test/providers/messages_provider_voice_test.dart @@ -0,0 +1,423 @@ +import 'dart:typed_data'; +import 'package:geolocator/geolocator.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/models/message_contact_location.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/services/location_tracking_service.dart'; +import 'package:meshcore_sar_app/utils/image_message_parser.dart'; +import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Contact buildContact({ + required List prefix, + required ContactType type, + String name = 'Test Contact', + }) { + final key = Uint8List.fromList([ + ...prefix, + ...List.generate(26, (index) => index), + ]); + + return Contact( + publicKey: key, + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + group('MessagesProvider voice detection', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + LocationTrackingService().currentPosition = null; + }); + + test('marks VE3 envelope messages as voice', () { + final provider = MessagesProvider(); + final envelope = VoiceEnvelope( + sessionId: 'deafbead', + mode: VoicePacketMode.mode1200, + total: 3, + durationMs: 2400, + ); + + final message = Message( + id: 'm1', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: envelope.encodeText(), + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + deliveryStatus: MessageDeliveryStatus.sent, + ); + + provider.addMessage(message); + final stored = provider.messages.single; + expect(stored.isVoice, isTrue); + expect(stored.voiceId, equals('deafbead')); + }); + + test('does not mark legacy V text packets as voice', () { + final provider = MessagesProvider(); + final packet = VoicePacket( + sessionId: '00112233', + mode: VoicePacketMode.mode700c, + index: 0, + total: 1, + codec2Data: Uint8List.fromList([1, 2, 3]), + ); + final message = Message( + id: 'm2', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000001, + text: packet.encodeText(), + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + deliveryStatus: MessageDeliveryStatus.sent, + ); + + provider.addMessage(message); + final stored = provider.messages.single; + expect(stored.isVoice, isFalse); + expect(stored.voiceId, isNull); + }); + + test('persists received contact location snapshots', () async { + final provider = MessagesProvider(); + final message = Message( + id: 'm3', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000002, + text: 'status update', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + ); + + provider.addMessage( + message, + contactLocationSnapshot: MessageContactLocation( + location: const LatLng(46.0569, 14.5058), + source: 'advert', + capturedAt: DateTime.now(), + sourceTimestamp: DateTime.now(), + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + final restoredProvider = MessagesProvider(); + await restoredProvider.initialize(); + final snapshot = restoredProvider.getMessageContactLocation('m3'); + + expect(snapshot, isNotNull); + expect(snapshot!.source, equals('advert')); + expect(snapshot.location.latitude, closeTo(46.0569, 0.000001)); + expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); + }); + + test('persists current device location for sent messages', () async { + final provider = MessagesProvider(); + LocationTrackingService().currentPosition = Position( + latitude: 46.0569, + longitude: 14.5058, + timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000), + accuracy: 4.5, + altitude: 310.0, + altitudeAccuracy: 6.0, + heading: 0.0, + headingAccuracy: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + ); + + provider.addSentMessage( + Message( + id: 'sent-1', + messageType: MessageType.contact, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000003, + text: 'outgoing status', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + deliveryStatus: MessageDeliveryStatus.sending, + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + final restoredProvider = MessagesProvider(); + await restoredProvider.initialize(); + final snapshot = restoredProvider.getMessageContactLocation('sent-1'); + + expect(snapshot, isNotNull); + expect(snapshot!.source, equals('gps')); + expect(snapshot.location.latitude, closeTo(46.0569, 0.000001)); + expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); + expect( + snapshot.sourceTimestamp, + DateTime.fromMillisecondsSinceEpoch(1700000003000), + ); + }); + + test('dedupes repeated incoming contact messages with same text', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]); + + provider.addMessage( + Message( + id: 'dup-1', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700001000, + text: 'same payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ); + provider.addMessage( + Message( + id: 'dup-2', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700001999, + text: 'same payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ); + + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('dup-1')); + }); + + test('keeps separate incoming messages when text differs', () { + final provider = MessagesProvider(); + final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]); + + provider.addMessage( + Message( + id: 'unique-1', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700002000, + text: 'same payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ); + provider.addMessage( + Message( + id: 'unique-2', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700002999, + text: 'different payload', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: sender, + ), + ); + + expect(provider.messages, hasLength(2)); + }); + + test('removed SAR markers stay hidden after provider restore', () async { + final provider = MessagesProvider(); + final message = Message( + id: 'sar-hidden', + messageType: MessageType.channel, + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000003, + text: 'S:🧑:0:46.0569,14.5058:Hidden marker', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + ); + + provider.addMessage(message); + expect( + provider.sarMarkers.map((marker) => marker.id), + contains(message.id), + ); + + await provider.removeSarMarker(message.id); + expect(provider.sarMarkers, isEmpty); + + final restoredProvider = MessagesProvider(); + await restoredProvider.initialize(); + + expect( + restoredProvider.messages.map((stored) => stored.id), + contains(message.id), + ); + expect( + restoredProvider.sarMarkers.map((marker) => marker.id), + isNot(contains(message.id)), + ); + expect(restoredProvider.removedSarMarkerIds, contains(message.id)); + }); + + test('tracks and persists media transfer counts and downloaders', () async { + final provider = MessagesProvider(); + final voiceEnvelope = VoiceEnvelope( + sessionId: 'deafbead', + mode: VoicePacketMode.mode1200, + total: 3, + durationMs: 2400, + ); + const imageEnvelope = ImageEnvelope( + sessionId: '01020304', + format: ImageFormat.avif, + total: 2, + width: 64, + height: 64, + sizeBytes: 2048, + ); + + provider.addMessage( + Message( + id: 'voice1', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000010, + text: voiceEnvelope.encodeText(), + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + ), + ); + provider.addMessage( + Message( + id: 'image1', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000011, + text: imageEnvelope.encode(), + receivedAt: DateTime.now(), + senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]), + ), + ); + + provider.recordMediaTransfer( + sessionId: 'deafbead', + mediaType: 'voice', + requesterKey6: '112233445566', + requesterName: 'Alice', + ); + provider.recordMediaTransfer( + sessionId: 'deafbead', + mediaType: 'voice', + requesterKey6: '112233445566', + requesterName: 'Alice', + ); + provider.recordMediaTransfer( + sessionId: '01020304', + mediaType: 'image', + requesterKey6: 'a1b2c3d4e5f6', + requesterName: 'Bob', + ); + await Future.delayed(const Duration(milliseconds: 50)); + + final voiceDetails = provider.getMessageTransferDetails('voice1'); + final imageDetails = provider.getMessageTransferDetails('image1'); + expect(voiceDetails, isNotNull); + expect(voiceDetails!.totalTransfers, equals(2)); + expect(voiceDetails.downloaders.single.requesterName, equals('Alice')); + expect(voiceDetails.downloaders.single.transferCount, equals(2)); + expect(provider.transferCountForSession(voiceSessionId: 'deafbead'), 2); + expect(imageDetails?.totalTransfers, equals(1)); + expect(provider.transferCountForSession(imageSessionId: '01020304'), 1); + + final restoredProvider = MessagesProvider(); + await restoredProvider.initialize(); + final restoredVoice = restoredProvider.getMessageTransferDetails( + 'voice1', + ); + final restoredImage = restoredProvider.getMessageTransferDetails( + 'image1', + ); + expect(restoredVoice?.totalTransfers, equals(2)); + expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566'); + expect(restoredImage?.downloaders.single.requesterName, equals('Bob')); + }); + + test('marks only the selected contact destination as read', () { + final provider = MessagesProvider(); + final alice = buildContact( + prefix: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15], + type: ContactType.chat, + name: 'Alice', + ); + final bob = buildContact( + prefix: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25], + type: ContactType.chat, + name: 'Bob', + ); + + provider.addMessage( + Message( + id: 'alice-incoming', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000100, + text: 'Alice unread', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: alice.publicKey.sublist(0, 6), + ), + ); + provider.addMessage( + Message( + id: 'bob-incoming', + messageType: MessageType.contact, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000101, + text: 'Bob unread', + receivedAt: DateTime.now(), + senderPublicKeyPrefix: bob.publicKey.sublist(0, 6), + ), + ); + + provider.markDestinationAsRead( + destinationType: 'contact', + contact: alice, + ); + + final aliceMessage = provider.messages.firstWhere( + (message) => message.id == 'alice-incoming', + ); + final bobMessage = provider.messages.firstWhere( + (message) => message.id == 'bob-incoming', + ); + + expect(aliceMessage.isRead, isTrue); + expect(bobMessage.isRead, isFalse); + expect(provider.unreadCount, equals(1)); + }); + }); +} diff --git a/test/providers/sensors_provider_test.dart b/test/providers/sensors_provider_test.dart new file mode 100644 index 0000000..70ebf33 --- /dev/null +++ b/test/providers/sensors_provider_test.dart @@ -0,0 +1,550 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/device_info.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeContactsProvider extends ContactsProvider { + _FakeContactsProvider(this._contacts); + + final List _contacts; + + @override + List get contacts => _contacts; +} + +class _FakeConnectionProvider extends ConnectionProvider { + _FakeConnectionProvider({ + required bool isConnected, + Uint8List? publicKey, + String? selfName, + }) : _isConnected = isConnected, + _publicKey = publicKey, + _selfName = selfName; + + final bool _isConnected; + final Uint8List? _publicKey; + final String? _selfName; + + int pingCalls = 0; + + @override + DeviceInfo get deviceInfo => DeviceInfo( + connectionState: _isConnected + ? ConnectionState.connected + : ConnectionState.disconnected, + publicKey: _publicKey, + selfName: _selfName, + ); + + @override + Future smartPing({ + required Uint8List contactPublicKey, + required bool hasPath, + Function()? onRetryWithFlooding, + }) async { + pingCalls += 1; + return const PingResult( + success: true, + usedFlooding: false, + timedOut: false, + ); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + ProfileStorageScope.setScope( + profilesEnabled: false, + activeProfileId: 'default', + ); + }); + + Future waitUntilLoaded(SensorsProvider provider) async { + for (var i = 0; i < 20 && !provider.isLoaded; i++) { + await Future.delayed(Duration.zero); + } + expect(provider.isLoaded, isTrue); + } + + Contact buildSensorContact({ + int firstByte = 0x44, + String name = 'WX Station', + }) { + final publicKey = Uint8List(32); + publicKey[0] = firstByte; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + Contact buildTelemetrySensorContact() { + final publicKey = Uint8List(32); + publicKey[0] = 0x55; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Weather Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + temperature: 11.8, + humidity: 51, + pressure: 921.9, + timestamp: DateTime.now(), + extraSensorData: const { + '__source_channel:temperature': 3, + '__source_channel:humidity': 2, + '__source_channel:pressure': 2, + 'illuminance_2': 19150.0, + 'temperature_2': 1.99, + 'voltage_2': 5.2, + 'switch_2': 0, + 'speed_2': 2.8, + 'speed_3': 3.7, + 'uv_2': 1.0, + }, + ), + ); + } + + test('metric label overrides persist across reloads', () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + await provider.setMetricLabel( + contact.publicKeyHex, + 'extra:illuminance_2', + 'Solar', + ); + + expect( + provider.labelOverrideFor(contact.publicKeyHex, 'extra:illuminance_2'), + 'Solar', + ); + + final reloadedProvider = SensorsProvider(); + await waitUntilLoaded(reloadedProvider); + + expect( + reloadedProvider.labelOverrideFor( + contact.publicKeyHex, + 'extra:illuminance_2', + ), + 'Solar', + ); + }); + + test('metric order persists across reloads', () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + await provider.moveMetric( + contact.publicKeyHex, + availableFieldKeys: const ['voltage', 'battery', 'temperature'], + oldIndex: 2, + newIndex: 0, + ); + + expect( + provider.metricOrderFor(contact.publicKeyHex, const [ + 'voltage', + 'battery', + 'temperature', + ]), + const ['temperature', 'voltage', 'battery'], + ); + + final reloadedProvider = SensorsProvider(); + await waitUntilLoaded(reloadedProvider); + + expect( + reloadedProvider.metricOrderFor(contact.publicKeyHex, const [ + 'voltage', + 'battery', + 'temperature', + ]), + const ['temperature', 'voltage', 'battery'], + ); + }); + + test('auto refresh minutes persist across reloads', () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1440); + + expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 1440); + + final reloadedProvider = SensorsProvider(); + await waitUntilLoaded(reloadedProvider); + + expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 1440); + }); + + test('tracked auto refresh history is captured and persisted', () async { + SharedPreferences.setMockInitialValues({}); + final timestamp = DateTime(2026, 3, 15, 9, 0); + final contacts = [ + buildSensorContact().copyWith( + telemetry: ContactTelemetry( + temperature: 12.5, + humidity: 54, + pressure: 918.2, + timestamp: timestamp, + extraSensorData: const {'illuminance_2': 150.0}, + ), + ), + ]; + final contactsProvider = _FakeContactsProvider(contacts); + final connectionProvider = _FakeConnectionProvider(isConnected: true); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contacts.first); + await provider.setAutoRefreshMinutes(contacts.first.publicKeyHex, 5); + + await provider.captureTrackedTelemetryHistory( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + + final firstHistory = provider.historyFor(contacts.first.publicKeyHex); + expect(firstHistory, hasLength(1)); + expect(firstHistory.first.values['temperature'], 12.5); + expect(firstHistory.first.values['humidity'], 54); + expect(firstHistory.first.values['pressure'], 918.2); + expect(firstHistory.first.values['extra:illuminance_2'], 150.0); + + await provider.captureTrackedTelemetryHistory( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(provider.historyFor(contacts.first.publicKeyHex), hasLength(1)); + + contacts[0] = contacts[0].copyWith( + telemetry: ContactTelemetry( + temperature: 13.1, + humidity: 52, + pressure: 919.0, + timestamp: timestamp.add(const Duration(minutes: 5)), + extraSensorData: const {'illuminance_2': 160.0}, + ), + ); + + await provider.captureTrackedTelemetryHistory( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + + final updatedHistory = provider.historyFor(contacts.first.publicKeyHex); + expect(updatedHistory, hasLength(2)); + expect(updatedHistory.last.values['temperature'], 13.1); + + final reloadedProvider = SensorsProvider(); + await waitUntilLoaded(reloadedProvider); + expect(reloadedProvider.historyFor(contacts.first.publicKeyHex), hasLength(2)); + }); + + test('watched sensors and preferences are isolated per profile', () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + + ProfileStorageScope.setScope( + profilesEnabled: false, + activeProfileId: 'default', + ); + final defaultProvider = SensorsProvider(); + await waitUntilLoaded(defaultProvider); + await defaultProvider.addSensor(contact); + await defaultProvider.setMetricLabel( + contact.publicKeyHex, + 'voltage', + 'Default Voltage', + ); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); + final customProvider = SensorsProvider(); + await waitUntilLoaded(customProvider); + + expect(customProvider.watchedSensorKeys, isEmpty); + expect( + customProvider.labelOverrideFor(contact.publicKeyHex, 'voltage'), + isNull, + ); + + await customProvider.addSensor(contact); + await customProvider.setMetricLabel( + contact.publicKeyHex, + 'voltage', + 'Alpha Voltage', + ); + + ProfileStorageScope.setScope( + profilesEnabled: false, + activeProfileId: 'default', + ); + final reloadedDefaultProvider = SensorsProvider(); + await waitUntilLoaded(reloadedDefaultProvider); + expect(reloadedDefaultProvider.watchedSensorKeys, [contact.publicKeyHex]); + expect( + reloadedDefaultProvider.labelOverrideFor(contact.publicKeyHex, 'voltage'), + 'Default Voltage', + ); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); + final reloadedCustomProvider = SensorsProvider(); + await waitUntilLoaded(reloadedCustomProvider); + expect(reloadedCustomProvider.watchedSensorKeys, [contact.publicKeyHex]); + expect( + reloadedCustomProvider.labelOverrideFor(contact.publicKeyHex, 'voltage'), + 'Alpha Voltage', + ); + }); + + test('watched sensor order persists across reloads', () async { + SharedPreferences.setMockInitialValues({}); + final first = buildSensorContact(firstByte: 0x44, name: 'First'); + final second = buildSensorContact(firstByte: 0x45, name: 'Second'); + final third = buildSensorContact(firstByte: 0x46, name: 'Third'); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(first); + await provider.addSensor(second); + await provider.addSensor(third); + + await provider.reorderSensors(2, 0); + + expect(provider.watchedSensorKeys, [ + third.publicKeyHex, + first.publicKeyHex, + second.publicKeyHex, + ]); + + final reloadedProvider = SensorsProvider(); + await waitUntilLoaded(reloadedProvider); + + expect(reloadedProvider.watchedSensorKeys, [ + third.publicKeyHex, + first.publicKeyHex, + second.publicKeyHex, + ]); + }); + + test( + 'unsupported auto refresh minutes normalize to nearest option', + () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1); + + expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 5); + }, + ); + + test('refreshDueSensors respects per-contact interval', () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + final contactsProvider = _FakeContactsProvider([contact]); + final connectionProvider = _FakeConnectionProvider(isConnected: true); + final start = DateTime(2026, 3, 15, 9, 0); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + await provider.setAutoRefreshMinutes(contact.publicKeyHex, 5); + + await provider.refreshDueSensors( + now: start, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 1); + + await provider.refreshDueSensors( + now: start.add(const Duration(minutes: 4)), + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 1); + + await provider.refreshDueSensors( + now: start.add(const Duration(minutes: 5)), + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 2); + }); + + test('refreshDueSensors refreshes self every 30 seconds', () async { + SharedPreferences.setMockInitialValues({}); + final selfKey = Uint8List(32)..[0] = 0x66; + final contactsProvider = ContactsProvider(); + await contactsProvider.initialize(devicePublicKey: selfKey); + final connectionProvider = _FakeConnectionProvider( + isConnected: true, + publicKey: selfKey, + selfName: 'My Device', + ); + final start = DateTime(2026, 3, 22, 9, 0); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + + await provider.refreshDueSensors( + now: start, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 1); + + await provider.refreshDueSensors( + now: start.add(const Duration(seconds: 30)), + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 2); + + await provider.refreshDueSensors( + now: start.add(const Duration(seconds: 59)), + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 2); + + await provider.refreshDueSensors( + now: start.add(const Duration(minutes: 1)), + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 3); + }); + + test( + 'addSensor includes available extra telemetry fields by default', + () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildTelemetrySensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + + expect( + provider.visibleFieldsFor(contact.publicKeyHex), + containsAll({ + 'temperature', + 'humidity', + 'pressure', + 'extra:illuminance_2', + 'extra:temperature_2', + 'extra:voltage_2', + 'extra:switch_2', + 'extra:speed_2', + 'extra:speed_3', + 'extra:uv_2', + }), + ); + }, + ); + + test('selfContact includes stored self telemetry', () async { + SharedPreferences.setMockInitialValues({}); + final selfKey = Uint8List(32)..[0] = 0x66; + final contactsProvider = ContactsProvider(); + await contactsProvider.initialize(devicePublicKey: selfKey); + contactsProvider.updateTelemetry( + selfKey.sublist(0, 6), + CayenneLppParser.createTemperatureData(19.5, channel: 1), + ); + + final connectionProvider = _FakeConnectionProvider( + isConnected: true, + publicKey: selfKey, + selfName: 'My Device', + ); + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + + final selfContact = provider.selfContact( + contactsProvider, + connectionProvider, + ); + + expect(selfContact, isNotNull); + expect(selfContact!.telemetry, isNotNull); + expect(selfContact.telemetry!.temperature, closeTo(19.5, 0.1)); + }); + + test( + 'displaySelfKey still returns self when watched sensors exist', + () async { + SharedPreferences.setMockInitialValues({}); + final selfKey = Uint8List(32)..[0] = 0x66; + final sensor = buildSensorContact(firstByte: 0x44, name: 'Weather'); + final contactsProvider = ContactsProvider(); + await contactsProvider.initialize(devicePublicKey: selfKey); + + final connectionProvider = _FakeConnectionProvider( + isConnected: true, + publicKey: selfKey, + selfName: 'My Device', + ); + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(sensor); + + expect( + provider.displaySelfKey( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ), + selfKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), + ); + }, + ); +} diff --git a/test/screens/contacts_tab_test.dart b/test/screens/contacts_tab_test.dart new file mode 100644 index 0000000..55e09ca --- /dev/null +++ b/test/screens/contacts_tab_test.dart @@ -0,0 +1,348 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/channel.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/contact_group.dart'; +import 'package:meshcore_sar_app/models/device_info.dart' as device_info; +import 'package:meshcore_sar_app/providers/app_provider.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/drawing_provider.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart' + as image_provider; +import 'package:meshcore_sar_app/providers/map_provider.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/providers/voice_provider.dart'; +import 'package:meshcore_sar_app/screens/contacts_tab.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; +import 'package:meshcore_sar_app/services/voice_codec_service.dart'; +import 'package:meshcore_sar_app/services/voice_player_service.dart'; +import 'package:meshcore_sar_app/utils/contact_grouping.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeConnectionProvider extends ConnectionProvider { + _FakeConnectionProvider({required bool isConnected}) + : _isConnected = isConnected; + + final bool _isConnected; + + @override + device_info.DeviceInfo get deviceInfo => device_info.DeviceInfo( + connectionState: _isConnected + ? device_info.ConnectionState.connected + : device_info.ConnectionState.disconnected, + ); +} + +class _FakeMessagesProvider extends MessagesProvider { + @override + bool get isInitialized => true; +} + +class _FakeDrawingProvider extends DrawingProvider { + @override + bool get isInitialized => true; +} + +void main() { + String? clipboardText; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: false, + activeProfileId: 'default', + ); + clipboardText = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + switch (call.method) { + case 'Clipboard.setData': + clipboardText = + (call.arguments as Map)['text'] as String?; + return null; + case 'Clipboard.getData': + return {'text': clipboardText}; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + Contact buildChannel({required String name, required int channelIndex}) { + final publicKey = Uint8List(32); + publicKey[0] = 0xFF; + publicKey[1] = channelIndex; + + return Contact( + publicKey: publicKey, + type: ContactType.channel, + flags: 0, + outPathLen: -1, + outPath: Uint8List(0), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + Contact buildRepeater({required int seed, required String name}) { + final publicKey = Uint8List(32); + publicKey[0] = seed; + publicKey[1] = seed + 1; + + return Contact( + publicKey: publicKey, + type: ContactType.repeater, + flags: 0, + outPathLen: -1, + outPath: Uint8List(0), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 46056000 + seed, + advLon: 14505000 + seed, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + Contact buildSensor({required int seed, required String name}) { + final publicKey = Uint8List(32); + publicKey[0] = seed; + publicKey[1] = seed + 1; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: -1, + outPath: Uint8List(0), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 46056000 + seed, + advLon: 14505000 + seed, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + Future pumpContactsTab( + WidgetTester tester, { + List contacts = const [], + List savedGroups = const [], + ConnectionProvider? connectionProvider, + }) async { + final contactsProvider = ContactsProvider(); + final resolvedConnectionProvider = + connectionProvider ?? ConnectionProvider(); + final messagesProvider = _FakeMessagesProvider(); + final drawingProvider = _FakeDrawingProvider(); + final channelsProvider = ChannelsProvider(); + final voiceProvider = VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ); + final imageProvider = image_provider.ImageProvider(); + final appProvider = AppProvider( + connectionProvider: resolvedConnectionProvider, + contactsProvider: contactsProvider, + messagesProvider: messagesProvider, + drawingProvider: drawingProvider, + channelsProvider: channelsProvider, + voiceProvider: voiceProvider, + imageProvider: imageProvider, + ); + for (final contact in contacts) { + contactsProvider.addOrUpdateContact(contact); + } + if (savedGroups.isNotEmpty) { + await contactsProvider.replaceAutoGroupsForSection( + 'repeaters', + savedGroups, + ); + } + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => contactsProvider, + ), + ChangeNotifierProvider( + create: (_) => resolvedConnectionProvider, + ), + ChangeNotifierProvider( + create: (_) => messagesProvider, + ), + ChangeNotifierProvider(create: (_) => MapProvider()), + ChangeNotifierProvider(create: (_) => appProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: ContactsTab()), + ), + ), + ); + + await tester.pumpAndSettle(); + } + + testWidgets('private channel activity card shows delete action in sheet', ( + tester, + ) async { + await pumpContactsTab( + tester, + contacts: [buildChannel(name: 'Ops', channelIndex: 3)], + ); + + expect(find.text('Ops'), findsOneWidget); + await tester.tap(find.text('Ops')); + await tester.pumpAndSettle(); + + expect(find.text('Export psk_base64'), findsNothing); + expect(find.text('Delete Channel'), findsOneWidget); + + await tester.tap(find.text('Delete Channel')); + await tester.pumpAndSettle(); + + expect(find.text('Delete Channel'), findsWidgets); + expect( + find.text( + 'Are you sure you want to delete channel "Ops"? This action cannot be undone.', + ), + findsOneWidget, + ); + }); + + testWidgets('hash channel activity card exports psk_base64', (tester) async { + await pumpContactsTab( + tester, + contacts: [buildChannel(name: '#ops', channelIndex: 3)], + ); + + expect(find.text('#ops'), findsOneWidget); + await tester.tap(find.text('#ops')); + await tester.pumpAndSettle(); + + expect(find.text('Export psk_base64'), findsOneWidget); + + await tester.tap(find.text('Export psk_base64')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(clipboardText, Channel.pskBase64ForHashChannelName('#ops')); + expect(find.text('psk_base64 copied to clipboard'), findsOneWidget); + }); + + testWidgets('repeaters show Others group when multiple groups exist', ( + tester, + ) async { + final repeaters = [ + buildRepeater(seed: 10, name: 'AL-1'), + buildRepeater(seed: 11, name: 'AL-2'), + buildRepeater(seed: 12, name: 'AL-3'), + buildRepeater(seed: 13, name: 'AL-4'), + buildRepeater(seed: 20, name: 'BR-1'), + buildRepeater(seed: 21, name: 'BR-2'), + buildRepeater(seed: 22, name: 'BR-3'), + buildRepeater(seed: 23, name: 'BR-4'), + buildRepeater(seed: 30, name: 'Lone Relay'), + ]; + final inferredGroups = ContactGrouping.inferGroups(repeaters); + final savedGroups = inferredGroups + .map( + (group) => SavedContactGroup( + id: 'repeaters_${group.key}', + sectionKey: 'repeaters', + label: group.label, + query: group.label, + createdAt: DateTime(2026, 3, 13, 10), + matchPrefixes: group.matchPrefixes, + isAutoGroup: true, + ), + ) + .toList(); + + await pumpContactsTab( + tester, + contacts: repeaters, + savedGroups: savedGroups, + ); + + expect(find.text('AL-'), findsOneWidget); + expect(find.text('BR-'), findsOneWidget); + expect(find.text('Others'), findsOneWidget); + expect(find.text('Lone Relay'), findsNothing); + }); + + testWidgets('repeaters stay flat when only one group exists', (tester) async { + final repeaters = [ + buildRepeater(seed: 40, name: 'AL-1'), + buildRepeater(seed: 41, name: 'AL-2'), + buildRepeater(seed: 42, name: 'AL-3'), + buildRepeater(seed: 43, name: 'AL-4'), + buildRepeater(seed: 50, name: 'Lone Relay'), + ]; + final inferredGroups = ContactGrouping.inferGroups(repeaters); + final savedGroups = inferredGroups + .map( + (group) => SavedContactGroup( + id: 'repeaters_${group.key}', + sectionKey: 'repeaters', + label: group.label, + query: group.label, + createdAt: DateTime(2026, 3, 13, 10), + matchPrefixes: group.matchPrefixes, + isAutoGroup: true, + ), + ) + .toList(); + + await pumpContactsTab( + tester, + contacts: repeaters, + savedGroups: savedGroups, + ); + + expect(find.text('AL-'), findsOneWidget); + expect(find.text('Others'), findsNothing); + expect(find.text('Lone Relay'), findsOneWidget); + }); + + testWidgets('sensor contacts render in their own section', (tester) async { + await pumpContactsTab( + tester, + contacts: [buildSensor(seed: 60, name: 'WX Station')], + ); + + expect(find.text('Sensors'), findsWidgets); + expect(find.text('WX Station'), findsOneWidget); + }); + + testWidgets( + 'contacts sections do not show sensor or repeater discovery actions', + (tester) async { + await pumpContactsTab( + tester, + contacts: [ + buildRepeater(seed: 70, name: 'Relay 1'), + buildSensor(seed: 71, name: 'WX Station'), + ], + connectionProvider: _FakeConnectionProvider(isConnected: true), + ); + + expect(find.byTooltip('Discover repeaters'), findsNothing); + expect(find.byTooltip('Discover sensors'), findsNothing); + expect(find.byIcon(Icons.radar), findsNothing); + }, + ); +} diff --git a/test/screens/device_config_screen_test.dart b/test/screens/device_config_screen_test.dart new file mode 100644 index 0000000..712e7a0 --- /dev/null +++ b/test/screens/device_config_screen_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/device_info.dart' as device_info; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/screens/device_config_screen.dart'; +import 'package:provider/provider.dart'; + +class _FakeConnectionProvider extends ChangeNotifier + implements ConnectionProvider { + _FakeConnectionProvider(this._deviceInfo); + + device_info.DeviceInfo _deviceInfo; + + @override + device_info.DeviceInfo get deviceInfo => _deviceInfo; + + void updateDeviceInfo(device_info.DeviceInfo nextDeviceInfo) { + _deviceInfo = nextDeviceInfo; + notifyListeners(); + } + + @override + Future> getCustomVars() async => const {}; + + @override + Future getBatteryAndStorage() async {} + + @override + Future getAutoaddConfig() async {} + + @override + Future getAllowedRepeatFreq() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Future _pumpDeviceConfigScreen( + WidgetTester tester, { + required ConnectionProvider connectionProvider, +}) async { + tester.view.physicalSize = const Size(1440, 4000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: connectionProvider, + ), + ChangeNotifierProvider( + create: (_) => ChannelsProvider()..initializePublicChannel(), + ), + ChangeNotifierProvider( + create: (_) => ContactsProvider(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const DeviceConfigScreen(), + ), + ), + ); + + await tester.pump(); +} + +void main() { + testWidgets( + 'matches a preset when bandwidth arrives as raw Hz', + (tester) async { + final connectionProvider = _FakeConnectionProvider( + device_info.DeviceInfo( + connectionState: device_info.ConnectionState.connected, + radioFreq: 869618, + radioBw: 62500, + radioSf: 8, + radioCr: 8, + ), + ); + + await _pumpDeviceConfigScreen( + tester, + connectionProvider: connectionProvider, + ); + + expect(find.byKey(const ValueKey('eu_uk_narrow')), findsOneWidget); + expect(find.text('EU/UK (Narrow)'), findsWidgets); + }, + ); + + testWidgets( + 're-syncs the preset dropdown when device info changes', + (tester) async { + final connectionProvider = _FakeConnectionProvider( + device_info.DeviceInfo( + connectionState: device_info.ConnectionState.connected, + ), + ); + + await _pumpDeviceConfigScreen( + tester, + connectionProvider: connectionProvider, + ); + + expect(find.byKey(const ValueKey('custom')), findsOneWidget); + + connectionProvider.updateDeviceInfo( + device_info.DeviceInfo( + connectionState: device_info.ConnectionState.connected, + radioFreq: 869618, + radioBw: 62500, + radioSf: 8, + radioCr: 8, + ), + ); + await tester.pump(); + + expect(find.byKey(const ValueKey('eu_uk_narrow')), findsOneWidget); + }, + ); +} diff --git a/test/screens/discovery_screen_test.dart b/test/screens/discovery_screen_test.dart new file mode 100644 index 0000000..5dcf5b7 --- /dev/null +++ b/test/screens/discovery_screen_test.dart @@ -0,0 +1,206 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/device_info.dart' as device_info; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/screens/discovery_screen.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeConnectionProvider extends ConnectionProvider { + _FakeConnectionProvider({required bool isConnected}) + : _isConnected = isConnected; + + final bool _isConnected; + final List discoveredAdvertTypes = []; + + @override + device_info.DeviceInfo get deviceInfo => device_info.DeviceInfo( + connectionState: _isConnected + ? device_info.ConnectionState.connected + : device_info.ConnectionState.disconnected, + ); + + @override + Future discoverNodeType({ + required int advertType, + bool prefixOnly = false, + int since = 0, + }) async { + discoveredAdvertTypes.add(advertType); + } +} + +Future _pumpDiscoveryScreen( + WidgetTester tester, { + ContactsProvider? contactsProvider, + ConnectionProvider? connectionProvider, + DiscoveryScreen screen = const DiscoveryScreen(), +}) async { + final resolvedContactsProvider = contactsProvider ?? ContactsProvider(); + final resolvedConnectionProvider = + connectionProvider ?? _FakeConnectionProvider(isConnected: true); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: resolvedContactsProvider, + ), + ChangeNotifierProvider.value( + value: resolvedConnectionProvider, + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: screen, + ), + ), + ); + + await tester.pumpAndSettle(); +} + +Uint8List _publicKey(int seed) { + final bytes = Uint8List(32); + bytes[0] = seed; + bytes[1] = seed + 1; + bytes[2] = seed + 2; + return bytes; +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets( + 'discovery actions are shown in the summary card instead of overflow menu', + (tester) async { + tester.view.physicalSize = const Size(375, 812); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final connectionProvider = _FakeConnectionProvider(isConnected: true); + + await _pumpDiscoveryScreen( + tester, + connectionProvider: connectionProvider, + ); + + expect(tester.takeException(), isNull); + expect(find.text('Discover repeaters'), findsOneWidget); + expect(find.text('Discover sensors'), findsOneWidget); + expect(find.text('Resolve all'), findsOneWidget); + expect(find.text('Clear all'), findsOneWidget); + expect(find.text('Search discovered nodes'), findsNothing); + expect(find.byIcon(Icons.more_vert), findsNothing); + expect( + find.text( + 'Use the discovery actions above to find repeaters and sensors on the mesh.', + ), + findsOneWidget, + ); + + await tester.tap(find.text('Discover repeaters')); + await tester.pump(); + + expect(connectionProvider.discoveredAdvertTypes, [2]); + expect(find.text('Repeater discovery sent'), findsOneWidget); + }, + ); + + testWidgets('auto discovery can trigger repeater discovery on open', ( + tester, + ) async { + final connectionProvider = _FakeConnectionProvider(isConnected: true); + + await _pumpDiscoveryScreen( + tester, + connectionProvider: connectionProvider, + screen: const DiscoveryScreen(autoDiscoverRepeatersOnOpen: true), + ); + + expect(connectionProvider.discoveredAdvertTypes, [2]); + expect(find.text('Repeater discovery sent'), findsOneWidget); + }); + + testWidgets('search and inline type filters narrow discovered nodes', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final contactsProvider = ContactsProvider() + ..addOrUpdatePendingAdvertMetadata( + publicKey: _publicKey(0x10), + typeValue: 2, + advName: 'Relay Alpha', + ) + ..addOrUpdatePendingAdvertMetadata( + publicKey: _publicKey(0x20), + typeValue: 4, + advName: 'WX Station', + ) + ..addOrUpdatePendingAdvertMetadata( + publicKey: _publicKey(0x30), + typeValue: 3, + advName: 'Ops Room', + ); + + await _pumpDiscoveryScreen( + tester, + contactsProvider: contactsProvider, + connectionProvider: _FakeConnectionProvider(isConnected: true), + ); + + expect(find.text('Search discovered nodes'), findsOneWidget); + expect(find.text('All'), findsOneWidget); + expect(find.text('Repeaters'), findsOneWidget); + expect(find.text('Sensors'), findsOneWidget); + expect(find.text('Others'), findsOneWidget); + + expect(find.text('Relay Alpha'), findsOneWidget); + expect(find.text('WX Station'), findsOneWidget); + expect(find.text('Ops Room'), findsOneWidget); + + await tester.tap(find.text('Sensors')); + await tester.pumpAndSettle(); + + expect(find.text('Relay Alpha'), findsNothing); + expect(find.text('WX Station'), findsOneWidget); + expect(find.text('Ops Room'), findsNothing); + expect(find.text('Discovered nodes (1/3)'), findsOneWidget); + + await tester.tap(find.text('Others')); + await tester.pumpAndSettle(); + + expect(find.text('Relay Alpha'), findsNothing); + expect(find.text('WX Station'), findsNothing); + expect(find.text('Ops Room'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'relay'); + await tester.pumpAndSettle(); + + expect(find.text('No matches'), findsOneWidget); + + await tester.tap(find.text('All')); + await tester.pumpAndSettle(); + + expect(find.text('Relay Alpha'), findsOneWidget); + expect(find.text('Ops Room'), findsNothing); + + await tester.enterText(find.byType(TextField), 'ops'); + await tester.pumpAndSettle(); + + expect(find.text('Relay Alpha'), findsNothing); + expect(find.text('WX Station'), findsNothing); + expect(find.text('Ops Room'), findsOneWidget); + expect(find.text('Discovered nodes (1/3)'), findsOneWidget); + }); +} diff --git a/test/screens/live_traffic_screen_test.dart b/test/screens/live_traffic_screen_test.dart new file mode 100644 index 0000000..b0f1c39 --- /dev/null +++ b/test/screens/live_traffic_screen_test.dart @@ -0,0 +1,367 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/channel.dart'; +import 'package:meshcore_sar_app/models/ble_packet_log.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; +import 'package:meshcore_sar_app/screens/live_traffic_screen.dart'; +import 'package:provider/provider.dart'; + +BlePacketLog _log({ + required DateTime timestamp, + required PacketDirection direction, + required List rawData, + int? responseCode, + double? snrDb, + int? rssiDbm, +}) { + return BlePacketLog( + timestamp: timestamp, + rawData: Uint8List.fromList(rawData), + direction: direction, + responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first), + logRxDataInfo: snrDb == null && rssiDbm == null + ? null + : LogRxDataInfo( + entropy: 0, + isLikelyEncrypted: false, + snrDb: snrDb, + rssiDbm: rssiDbm, + ), + ); +} + +List _multiHopRaw({ + required List hops, + int payloadType = 0x01, + int hashSize = 2, +}) { + final hopCount = hops.length ~/ hashSize; + final pathDescriptor = ((hashSize - 1) << 6) | hopCount; + return [ + 0x88, + 0x00, + 0x00, + payloadType << 2, + 0x00, + 0x00, + 0x00, + 0x00, + pathDescriptor, + ...hops, + ]; +} + +Widget _testApp(Widget child, {ChannelsProvider? channelsProvider}) { + final app = MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ); + if (channelsProvider == null) { + return app; + } + return ChangeNotifierProvider.value(value: channelsProvider, child: app); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final launchedUrls = []; + + setUp(() { + launchedUrls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + (call) async { + switch (call.method) { + case 'canLaunch': + return true; + case 'launch': + final arguments = Map.from( + call.arguments as Map, + ); + launchedUrls.add(arguments['url'] as String); + return true; + } + return null; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + null, + ); + }); + + testWidgets('shows empty state before traffic arrives', (tester) async { + final logs = []; + final refresh = ValueNotifier(0); + DateTime now = DateTime(2026, 3, 12, 12, 0, 0); + + await tester.pumpWidget( + _testApp( + LiveTrafficScreen( + logReader: () => logs, + refreshListenable: refresh, + now: () => now, + ), + ), + ); + + expect(find.text('No packets for this filter'), findsOneWidget); + expect(find.text('Quiet'), findsOneWidget); + }); + + testWidgets('updates summary and stream for incoming live traffic', ( + tester, + ) async { + final logs = []; + final refresh = ValueNotifier(0); + DateTime now = DateTime(2026, 3, 12, 12, 0, 0); + + await tester.pumpWidget( + _testApp( + LiveTrafficScreen( + logReader: () => logs, + refreshListenable: refresh, + now: () => now, + ), + ), + ); + + logs.addAll([ + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.rx, + rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]), + responseCode: 0x88, + snrDb: 13.5, + rssiDbm: -84, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 3)), + direction: PacketDirection.tx, + rawData: [0x05, 0x01, 0x02], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 2)), + direction: PacketDirection.rx, + rawData: [0x05, 0x01, 0x02], + responseCode: 0x05, + ), + ]); + refresh.value += 1; + await tester.pump(); + + expect(find.text('1 pkt/min'), findsOneWidget); + expect(find.text('Device total 1'), findsOneWidget); + expect(find.text('FLOOD RESPONSE'), findsOneWidget); + expect(find.text('-84 dBm'), findsOneWidget); + expect(find.textContaining('Hash:'), findsOneWidget); + expect( + find.textContaining('Path: 3 hops [c010,6301,68d9]'), + findsOneWidget, + ); + expect(find.textContaining('Path Hashes: 2-byte per hop'), findsOneWidget); + }); + + testWidgets('clear live view only resets transient screen state', ( + tester, + ) async { + final logs = []; + final refresh = ValueNotifier(0); + DateTime now = DateTime(2026, 3, 12, 12, 0, 0); + + logs.add( + _log( + timestamp: now.subtract(const Duration(seconds: 4)), + direction: PacketDirection.rx, + rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]), + responseCode: 0x88, + ), + ); + + await tester.pumpWidget( + _testApp( + LiveTrafficScreen( + logReader: () => logs, + refreshListenable: refresh, + now: () => now, + ), + ), + ); + + expect(find.text('FLOOD RESPONSE'), findsOneWidget); + + await tester.tap(find.byTooltip('Clear live view')); + await tester.pump(); + + expect(find.text('No packets for this filter'), findsOneWidget); + + now = now.add(const Duration(seconds: 2)); + logs.add( + _log( + timestamp: now, + direction: PacketDirection.tx, + rawData: [0x03, 0x04], + responseCode: 0x88, + ), + ); + logs.add( + _log( + timestamp: now, + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + ); + refresh.value += 1; + await tester.pump(); + + expect(find.text('No packets for this filter'), findsNothing); + expect(find.textContaining('Size: 3 bytes'), findsOneWidget); + }); + + testWidgets('shows known channel name for group traffic', (tester) async { + final logs = []; + final refresh = ValueNotifier(0); + final channel = Channel.create(index: 3, name: '#ops'); + final channelsProvider = ChannelsProvider() + ..initializePublicChannel() + ..addOrUpdateChannelObject(channel); + final now = DateTime(2026, 3, 12, 12, 0, 0); + + logs.add( + _log( + timestamp: now.subtract(const Duration(seconds: 4)), + direction: PacketDirection.rx, + rawData: [ + ..._multiHopRaw(hops: [0xC0, 0x10], payloadType: 0x05), + channel.hashByte, + ], + responseCode: 0x88, + ), + ); + + await tester.pumpWidget( + _testApp( + LiveTrafficScreen( + logReader: () => logs, + refreshListenable: refresh, + now: () => now, + ), + channelsProvider: channelsProvider, + ), + ); + + expect(find.text('#ops'), findsOneWidget); + expect(find.text('Channel: #ops (${channel.hashHex})'), findsOneWidget); + expect(find.text('FLOOD GROUP_TEXT'), findsNothing); + }); + + testWidgets('shows packet type help sheet from the app bar', (tester) async { + final logs = []; + final refresh = ValueNotifier(0); + final now = DateTime(2026, 3, 12, 12, 0, 0); + + await tester.pumpWidget( + _testApp( + LiveTrafficScreen( + logReader: () => logs, + refreshListenable: refresh, + now: () => now, + ), + ), + ); + + await tester.tap(find.byTooltip('Packet type help')); + await tester.pumpAndSettle(); + + expect(find.text('Packet Types'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('FLOOD RETURNED_PATH'), + 300, + scrollable: find.byType(Scrollable).last, + ); + await tester.pumpAndSettle(); + expect(find.text('FLOOD RETURNED_PATH'), findsOneWidget); + expect( + find.textContaining('stores that returned path as the peer\'s direct out-path'), + findsOneWidget, + ); + await tester.scrollUntilVisible( + find.text('FLOOD CONTROL'), + 300, + scrollable: find.byType(Scrollable).last, + ); + await tester.pumpAndSettle(); + expect(find.text('FLOOD CONTROL'), findsOneWidget); + }); + + testWidgets('opens public stats from the app bar', (tester) async { + final logs = []; + final refresh = ValueNotifier(0); + final now = DateTime(2026, 3, 12, 12, 0, 0); + + await tester.pumpWidget( + _testApp( + LiveTrafficScreen( + logReader: () => logs, + refreshListenable: refresh, + now: () => now, + ), + ), + ); + + await tester.tap(find.byTooltip('View public stats')); + await tester.pump(); + + expect(launchedUrls, ['https://mcstats.dz0ny.dev']); + }); + + testWidgets('summary metrics expand across wide layouts', (tester) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final now = DateTime(2026, 3, 12, 12, 0, 0); + final logs = [ + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.rx, + rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]), + responseCode: 0x88, + snrDb: 9.5, + rssiDbm: -82, + ), + ]; + + await tester.pumpWidget( + _testApp(LiveTrafficScreen(logReader: () => logs, now: () => now)), + ); + + expect( + tester + .getSize( + find.byKey(const ValueKey('liveTrafficMetric:rxPackets')), + ) + .width > + 200, + isTrue, + ); + expect( + tester + .getSize(find.byKey(const ValueKey('liveTrafficMetric:multihop'))) + .width > + 200, + isTrue, + ); + }); +} diff --git a/test/screens/messages_tab_test.dart b/test/screens/messages_tab_test.dart new file mode 100644 index 0000000..d83ad1d --- /dev/null +++ b/test/screens/messages_tab_test.dart @@ -0,0 +1,435 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/providers/app_provider.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/drawing_provider.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart' as ip; +import 'package:meshcore_sar_app/providers/map_provider.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/providers/voice_provider.dart'; +import 'package:meshcore_sar_app/screens/messages_tab.dart'; +import 'package:meshcore_sar_app/services/message_destination_preferences.dart'; +import 'package:meshcore_sar_app/services/voice_codec_service.dart'; +import 'package:meshcore_sar_app/services/voice_player_service.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:typed_data'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Contact buildContact({ + required String name, + required ContactType type, + int secondByte = 1, + }) { + final publicKey = Uint8List(32); + publicKey[0] = secondByte; + publicKey[1] = secondByte; + + return Contact( + publicKey: publicKey, + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: 0, + advLat: 0, + advLon: 0, + lastMod: 0, + ); + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('marks public channel as read after 5 seconds of viewing', ( + tester, + ) async { + final connectionProvider = ConnectionProvider(); + final contactsProvider = ContactsProvider(); + final messagesProvider = MessagesProvider(); + final mapProvider = MapProvider(); + final drawingProvider = DrawingProvider(); + final channelsProvider = ChannelsProvider()..initializePublicChannel(); + final voiceProvider = VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ); + final imageProvider = ip.ImageProvider(); + final appProvider = AppProvider( + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + messagesProvider: messagesProvider, + drawingProvider: drawingProvider, + channelsProvider: channelsProvider, + voiceProvider: voiceProvider, + imageProvider: imageProvider, + ); + + messagesProvider.addMessage( + Message( + id: 'public-unread', + messageType: MessageType.channel, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000100, + text: 'Unread on public channel', + receivedAt: DateTime.now(), + channelIdx: 0, + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: connectionProvider), + ChangeNotifierProvider.value(value: contactsProvider), + ChangeNotifierProvider.value(value: messagesProvider), + ChangeNotifierProvider.value(value: mapProvider), + ChangeNotifierProvider.value(value: drawingProvider), + ChangeNotifierProvider.value(value: channelsProvider), + ChangeNotifierProvider.value(value: voiceProvider), + ChangeNotifierProvider.value(value: imageProvider), + ChangeNotifierProvider.value(value: appProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: MessagesTab(isActive: true)), + ), + ), + ); + + await tester.pump(); + + expect(messagesProvider.unreadCount, 1); + + await tester.pump(const Duration(seconds: 4)); + expect(messagesProvider.unreadCount, 1); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(messagesProvider.unreadCount, 0); + + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + mapProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + }); + + testWidgets('shows mention overlay above composer and inserts selection', ( + tester, + ) async { + final connectionProvider = ConnectionProvider(); + final contactsProvider = ContactsProvider(); + final messagesProvider = MessagesProvider(); + final mapProvider = MapProvider(); + final drawingProvider = DrawingProvider(); + await messagesProvider.initialize(); + await drawingProvider.initialize(); + final channelsProvider = ChannelsProvider()..initializePublicChannel(); + final voiceProvider = VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ); + final imageProvider = ip.ImageProvider(); + final appProvider = AppProvider( + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + messagesProvider: messagesProvider, + drawingProvider: drawingProvider, + channelsProvider: channelsProvider, + voiceProvider: voiceProvider, + imageProvider: imageProvider, + ); + + contactsProvider.addContacts([ + buildContact(name: 'Tim', type: ContactType.chat, secondByte: 2), + buildContact(name: 'Slane', type: ContactType.chat, secondByte: 3), + ]); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: connectionProvider), + ChangeNotifierProvider.value(value: contactsProvider), + ChangeNotifierProvider.value(value: messagesProvider), + ChangeNotifierProvider.value(value: mapProvider), + ChangeNotifierProvider.value(value: drawingProvider), + ChangeNotifierProvider.value(value: channelsProvider), + ChangeNotifierProvider.value(value: voiceProvider), + ChangeNotifierProvider.value(value: imageProvider), + ChangeNotifierProvider.value(value: appProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: MessagesTab(isActive: true)), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 60)); + + await tester.tap(find.byType(TextField).first); + await tester.enterText(find.byType(TextField).first, '@t'); + await tester.pumpAndSettle(); + + expect(find.text('Tim'), findsOneWidget); + expect(find.text('Slane'), findsNothing); + + await tester.tap(find.text('Tim')); + await tester.pumpAndSettle(); + + expect(find.text('@[Tim] '), findsOneWidget); + expect(find.text('Tim'), findsNothing); + + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + mapProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + }); + + testWidgets( + 'locks messages tab to the configured channel and removes the selector affordance', + (tester) async { + final lockedChannel = buildContact( + name: '#ops', + type: ContactType.channel, + secondByte: 2, + ); + SharedPreferences.setMockInitialValues({ + 'message_locked_destination_enabled': true, + 'message_locked_destination_type': + MessageDestinationPreferences.destinationTypeChannel, + 'message_locked_recipient_public_key': lockedChannel.publicKeyHex, + }); + + final connectionProvider = ConnectionProvider(); + final contactsProvider = ContactsProvider(); + final messagesProvider = MessagesProvider(); + final mapProvider = MapProvider(); + final drawingProvider = DrawingProvider(); + await messagesProvider.initialize(); + await drawingProvider.initialize(); + final channelsProvider = ChannelsProvider()..initializePublicChannel(); + final voiceProvider = VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ); + final imageProvider = ip.ImageProvider(); + final appProvider = AppProvider( + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + messagesProvider: messagesProvider, + drawingProvider: drawingProvider, + channelsProvider: channelsProvider, + voiceProvider: voiceProvider, + imageProvider: imageProvider, + ); + + contactsProvider.addContacts([lockedChannel]); + messagesProvider.addMessage( + Message( + id: 'public-message', + messageType: MessageType.channel, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000200, + text: 'Public chatter', + receivedAt: DateTime.now(), + channelIdx: 0, + ), + ); + messagesProvider.addMessage( + Message( + id: 'locked-message', + messageType: MessageType.channel, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000201, + text: 'Ops chatter', + receivedAt: DateTime.now(), + channelIdx: 2, + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: connectionProvider), + ChangeNotifierProvider.value(value: contactsProvider), + ChangeNotifierProvider.value(value: messagesProvider), + ChangeNotifierProvider.value(value: mapProvider), + ChangeNotifierProvider.value(value: drawingProvider), + ChangeNotifierProvider.value(value: channelsProvider), + ChangeNotifierProvider.value(value: voiceProvider), + ChangeNotifierProvider.value(value: imageProvider), + ChangeNotifierProvider.value(value: appProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: MessagesTab(isActive: true)), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Ops chatter'), findsOneWidget); + expect(find.text('Public chatter'), findsNothing); + expect( + find.byKey(const ValueKey('messages_composer_destination_locked')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('messages_composer_destination_selector')), + findsNothing, + ); + + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + mapProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + }, + ); + + testWidgets( + 'contact navigation temporarily overrides the lock while keeping the selector hidden', + (tester) async { + final directContact = buildContact( + name: 'Tim', + type: ContactType.chat, + secondByte: 9, + ); + SharedPreferences.setMockInitialValues({ + 'message_locked_destination_enabled': true, + 'message_locked_destination_type': + MessageDestinationPreferences.destinationTypeChannel, + }); + + final connectionProvider = ConnectionProvider(); + final contactsProvider = ContactsProvider(); + final messagesProvider = MessagesProvider(); + final mapProvider = MapProvider(); + final drawingProvider = DrawingProvider(); + await messagesProvider.initialize(); + await drawingProvider.initialize(); + final channelsProvider = ChannelsProvider()..initializePublicChannel(); + final voiceProvider = VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ); + final imageProvider = ip.ImageProvider(); + final appProvider = AppProvider( + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + messagesProvider: messagesProvider, + drawingProvider: drawingProvider, + channelsProvider: channelsProvider, + voiceProvider: voiceProvider, + imageProvider: imageProvider, + ); + + contactsProvider.addContacts([directContact]); + messagesProvider.addMessage( + Message( + id: 'public-message', + messageType: MessageType.channel, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000300, + text: 'Public chatter', + receivedAt: DateTime.now(), + channelIdx: 0, + ), + ); + messagesProvider.addMessage( + Message( + id: 'direct-message', + messageType: MessageType.contact, + senderPublicKeyPrefix: directContact.publicKey.sublist(0, 6), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000301, + text: 'Direct chatter', + receivedAt: DateTime.now(), + ), + ); + messagesProvider.navigateToDestination( + MessageDestinationPreferences.destinationTypeContact, + recipientPublicKeyHex: directContact.publicKeyHex, + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: connectionProvider), + ChangeNotifierProvider.value(value: contactsProvider), + ChangeNotifierProvider.value(value: messagesProvider), + ChangeNotifierProvider.value(value: mapProvider), + ChangeNotifierProvider.value(value: drawingProvider), + ChangeNotifierProvider.value(value: channelsProvider), + ChangeNotifierProvider.value(value: voiceProvider), + ChangeNotifierProvider.value(value: imageProvider), + ChangeNotifierProvider.value(value: appProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: MessagesTab(isActive: true)), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Direct chatter'), findsOneWidget); + expect(find.text('Public chatter'), findsNothing); + expect( + find.byKey(const ValueKey('messages_composer_destination_locked')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('messages_composer_destination_selector')), + findsNothing, + ); + + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + mapProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + }, + ); +} diff --git a/test/screens/sensors_tab_test.dart b/test/screens/sensors_tab_test.dart new file mode 100644 index 0000000..a5f85bd --- /dev/null +++ b/test/screens/sensors_tab_test.dart @@ -0,0 +1,122 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/screens/sensors_tab.dart'; +import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; + +void main() { + Contact buildSensorContact({ + int firstByte = 0x44, + String name = 'WX Station', + }) { + final publicKey = Uint8List(32); + publicKey[0] = firstByte; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + batteryPercentage: 84, + temperature: 21.5, + extraSensorData: const { + '__source_channel:battery': 1, + '__source_channel:temperature': 1, + 'illuminance_2': 500.0, + }, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + } + + testWidgets('metric selector item shows channel-specific preview', ( + tester, + ) async { + final contact = buildSensorContact(); + final option = sensorMetricOptionsFor(contact, labelOverrides: const {}) + .firstWhere( + (entry) => entry.key.contains('illuminance') && entry.channel == 2, + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorMetricSelectorItem( + option: option, + visible: true, + span: 1, + canMoveUp: false, + canMoveDown: false, + onToggle: (_) {}, + onRename: () {}, + onSpanChanged: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + expect( + find.byKey(ValueKey('sensor_selector_metric_${option.key}')), + findsOneWidget, + ); + expect(find.text('Channel 2'), findsOneWidget); + }); + + testWidgets('add sensor sheet keeps close action available when scrolled', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + var didClose = false; + final candidates = List.generate( + 20, + (index) => + buildSensorContact(firstByte: index + 1, name: 'Sensor ${index + 1}'), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: AddSensorSheet( + candidates: candidates, + onSelect: (_) async {}, + onClose: () { + didClose = true; + }, + ), + ), + ), + ); + await tester.pump(); + + final closeButton = find.byTooltip('Close'); + expect(closeButton, findsOneWidget); + + await tester.drag(find.byType(ListView).last, const Offset(0, -600)); + await tester.pump(); + + expect(closeButton, findsOneWidget); + + await tester.tap(closeButton); + await tester.pump(); + + expect(didClose, isTrue); + }); +} diff --git a/test/services/bthome_met_history_test.dart b/test/services/bthome_met_history_test.dart new file mode 100644 index 0000000..58b6a00 --- /dev/null +++ b/test/services/bthome_met_history_test.dart @@ -0,0 +1,84 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/services/bthome_met_history.dart'; + +void main() { + test('parses a valid BTHome MET history response', () { + final parsed = BTHomeMetHistoryParser.parse('1,0,4,11.2,11.8,12.1,12.3'); + + expect(parsed.measurement, BTHomeMetMeasurement.temperature); + expect(parsed.page, 0); + expect(parsed.values, [11.2, 11.8, 12.1, 12.3]); + expect(parsed.latest, 12.3); + expect(parsed.minimum, 11.2); + expect(parsed.maximum, 12.3); + }); + + test('rejects malformed BTHome MET history counts', () { + expect( + () => BTHomeMetHistoryParser.parse('2,0,3,41,42'), + throwsA(isA()), + ); + }); + + test('detects available BTHome MET measurements from telemetry', () { + final contact = Contact( + publicKey: Uint8List(32), + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX', + lastAdvert: 0, + advLat: 0, + advLon: 0, + lastMod: 0, + telemetry: ContactTelemetry( + temperature: 20.1, + humidity: 52, + extraSensorData: const { + 'generic_sensor_1': 1, + 'speed_2': 3.1, + 'gust_2': 4.8, + 'rain_2': 12.3, + }, + timestamp: DateTime(2026, 3, 21, 12), + ), + ); + + expect(bTHomeMetMeasurementsForContact(contact), [ + BTHomeMetMeasurement.temperature, + BTHomeMetMeasurement.humidity, + BTHomeMetMeasurement.windSpeed, + BTHomeMetMeasurement.gust, + BTHomeMetMeasurement.rain, + ]); + expect(supportsBTHomeMetHistory(contact), isTrue); + }); + + test('does not enable BTHome MET history without channel 1 capability', () { + final contact = Contact( + publicKey: Uint8List(32), + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX', + lastAdvert: 0, + advLat: 0, + advLon: 0, + lastMod: 0, + telemetry: ContactTelemetry( + temperature: 20.1, + humidity: 52, + extraSensorData: const {'speed_2': 3.1, 'gust_2': 4.8, 'rain_2': 12.3}, + timestamp: DateTime(2026, 3, 21, 12), + ), + ); + + expect(bTHomeMetMeasurementsForContact(contact), isEmpty); + expect(supportsBTHomeMetHistory(contact), isFalse); + }); +} diff --git a/test/services/cayenne_lpp_parser_test.dart b/test/services/cayenne_lpp_parser_test.dart new file mode 100644 index 0000000..814f523 --- /dev/null +++ b/test/services/cayenne_lpp_parser_test.dart @@ -0,0 +1,682 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; +import 'package:meshcore_client/meshcore_client.dart'; + +void main() { + group('CayenneLppParser - GPS Codec Tests', () { + test('GPS encoding uses correct 3-byte signed BE format', () { + // Test coordinates (Ljubljana, Slovenia) + const double lat = 46.0569; + const double lon = 14.5058; + const double alt = 295.0; + + final encoded = CayenneLppParser.createGpsData( + latitude: lat, + longitude: lon, + altitude: alt, + channel: 0, + ); + + // Expected format (Standard Cayenne LPP): + // [0] = channel (0) + // [1] = type (136 = 0x88 = lppGps) + // [2-4] = lat as 24-bit signed BE + // [5-7] = lon as 24-bit signed BE + // [8-10] = alt as 24-bit signed BE + expect(encoded.length, equals(11)); + expect(encoded[0], equals(0)); // channel + expect(encoded[1], equals(MeshCoreConstants.lppGps)); // type 0x88 + + // Verify big-endian encoding (3 bytes each) + final latEncoded = (encoded[2] << 16) | (encoded[3] << 8) | encoded[4]; + final lonEncoded = (encoded[5] << 16) | (encoded[6] << 8) | encoded[7]; + final altEncoded = (encoded[8] << 16) | (encoded[9] << 8) | encoded[10]; + + expect(latEncoded, equals(460569)); // 46.0569 * 10000 + expect(lonEncoded, equals(145058)); // 14.5058 * 10000 + expect(altEncoded, equals(29500)); // 295.0 * 100 + }); + + test('GPS decoding uses correct 3-byte BE format and divisor', () { + // Create raw GPS telemetry packet (standard Cayenne LPP format) + final buffer = []; + buffer.add(0); // channel + buffer.add(MeshCoreConstants.lppGps); // type 0x88 + + // Lat: 46.0569 * 10000 = 460569 = 0x070719 + buffer.add(0x07); // MSB + buffer.add(0x07); + buffer.add(0x19); // LSB + + // Lon: 14.5058 * 10000 = 145058 = 0x0236A2 + buffer.add(0x02); + buffer.add(0x36); + buffer.add(0xA2); + + // Alt: 295.0 * 100 = 29500 = 0x7 33C + buffer.add(0x00); + buffer.add(0x73); + buffer.add(0x3C); + + final telemetry = CayenneLppParser.parse(Uint8List.fromList(buffer)); + + expect(telemetry.gpsLocation, isNotNull); + expect(telemetry.gpsLocation!.latitude, closeTo(46.0569, 0.0001)); + expect(telemetry.gpsLocation!.longitude, closeTo(14.5058, 0.0001)); + + // Verify altitude is stored in extra data + expect(telemetry.extraSensorData, isNotNull); + expect(telemetry.extraSensorData!['altitude_0'], closeTo(295.0, 0.01)); + }); + + test('GPS round-trip encoding/decoding maintains precision', () { + // Test various coordinates + final testCases = [ + LatLng(46.0569, 14.5058), // Ljubljana + LatLng(37.7749, -122.4194), // San Francisco + LatLng(-33.8688, 151.2093), // Sydney + LatLng(0.0, 0.0), // Null Island + LatLng(89.9999, 179.9999), // Near max + LatLng(-89.9999, -179.9999), // Near min + ]; + + for (final coords in testCases) { + final encoded = CayenneLppParser.createGpsData( + latitude: coords.latitude, + longitude: coords.longitude, + altitude: 100.0, + ); + + final decoded = CayenneLppParser.parse(encoded); + + expect( + decoded.gpsLocation, + isNotNull, + reason: 'Failed to decode: $coords', + ); + expect( + decoded.gpsLocation!.latitude, + closeTo(coords.latitude, 0.0001), + reason: 'Latitude mismatch for $coords', + ); + expect( + decoded.gpsLocation!.longitude, + closeTo(coords.longitude, 0.0001), + reason: 'Longitude mismatch for $coords', + ); + } + }); + + test('GPS decoding validates coordinate ranges', () { + // This test documents that coordinates are decoded correctly + // and any validation warnings are logged (not enforced) + + // Valid coordinates should decode without issue + final validBuffer = []; + validBuffer.add(0); // channel + validBuffer.add(MeshCoreConstants.lppGps); + + // Lat: 45.0 * 10000 = 450000 = 0x06DDD0 (3 bytes BE) + validBuffer.addAll([0x06, 0xDD, 0xD0]); + // Lon: 10.0 * 10000 = 100000 = 0x0186A0 (3 bytes BE) + validBuffer.addAll([0x01, 0x86, 0xA0]); + // Alt: 0 (3 bytes BE) + validBuffer.addAll([0x00, 0x00, 0x00]); + + final telemetry = CayenneLppParser.parse(Uint8List.fromList(validBuffer)); + + expect(telemetry.gpsLocation, isNotNull); + expect(telemetry.gpsLocation!.latitude, closeTo(45.0, 0.0001)); + expect(telemetry.gpsLocation!.longitude, closeTo(10.0, 0.0001)); + }); + + test('GPS encoding handles negative coordinates correctly', () { + const lat = -33.8688; + const lon = -151.2093; + + final encoded = CayenneLppParser.createGpsData( + latitude: lat, + longitude: lon, + ); + + // Verify 3-byte signed encoding + // Lat: -33.8688 * 10000 = -338688 + // In 24-bit two's complement: -338688 + 0x1000000 = 16438528 = 0xFAD500 + final latEncoded = (encoded[2] << 16) | (encoded[3] << 8) | encoded[4]; + // Lon: -151.2093 * 10000 = -1512093 + // In 24-bit two's complement: -1512093 + 0x1000000 = 15265123 = 0xE8ED63 + final lonEncoded = (encoded[5] << 16) | (encoded[6] << 8) | encoded[7]; + + expect(latEncoded, equals(0xFAD500)); // Verify two's complement + expect(lonEncoded, equals(0xE8ED63)); + + // Verify decoding + final decoded = CayenneLppParser.parse(encoded); + expect(decoded.gpsLocation!.latitude, closeTo(lat, 0.0001)); + expect(decoded.gpsLocation!.longitude, closeTo(lon, 0.0001)); + }); + + test('GPS encoding with altitude uses correct precision', () { + final encoded = CayenneLppParser.createGpsData( + latitude: 0.0, + longitude: 0.0, + altitude: 1234.56, + ); + + // Altitude is at bytes 8-10 (3 bytes BE) + // Alt: 1234.56 * 100 = 123456 = 0x01E240 + final altEncoded = (encoded[8] << 16) | (encoded[9] << 8) | encoded[10]; + + // Altitude precision is 0.01m (divide by 100) + expect(altEncoded, equals(123456)); // 1234.56 * 100 + + final decoded = CayenneLppParser.parse(encoded); + expect(decoded.extraSensorData!['altitude_0'], closeTo(1234.56, 0.01)); + }); + + test('GPS encoding supports custom channel', () { + final encoded = CayenneLppParser.createGpsData( + latitude: 1.0, + longitude: 2.0, + channel: 5, + ); + + expect(encoded[0], equals(5)); // channel + + final decoded = CayenneLppParser.parse(encoded); + expect(decoded.gpsLocation, isNotNull); + expect(decoded.extraSensorData!['altitude_5'], isNotNull); + }); + + test( + 'OLD BUG: Using 4-byte LE instead of 3-byte BE caused wrong coords', + () { + // This test documents the bug that was fixed + // The old code used 4-byte int32 LE (MeshCore advertisement format) + // instead of 3-byte signed BE (standard Cayenne LPP format) + + // Real data from device: + // Hex: 06 f7 08 02 38 0e 01 2c 46 + final realData = [ + 0x00, 0x88, // channel 0, type GPS + 0x06, 0xf7, 0x08, // lat (3 bytes BE) + 0x02, 0x38, 0x0e, // lon (3 bytes BE) + 0x01, 0x2c, 0x46, // alt (3 bytes BE) + ]; + + // CORRECT decoding (3-byte BE): + final correctLat = + ((0x06 << 16) | (0xf7 << 8) | 0x08) / 10000.0; // 45.6456° + final correctLon = + ((0x02 << 16) | (0x38 << 8) | 0x0e) / 10000.0; // 14.5422° + + // OLD BUGGY decoding (4-byte LE - reads wrong bytes!): + // Would read: lat=06f70802, lon=38010e2c (completely wrong) + final buggyLatRaw = 0x02 | (0x08 << 8) | (0xf7 << 16) | (0x06 << 24); + final buggyLat = buggyLatRaw / 10000.0; // 3414.1958° (out of range!) + + // Verify current implementation decodes correctly + final telemetry = CayenneLppParser.parse(Uint8List.fromList(realData)); + expect(telemetry.gpsLocation, isNotNull); + expect(telemetry.gpsLocation!.latitude, closeTo(correctLat, 0.0001)); + expect(telemetry.gpsLocation!.longitude, closeTo(correctLon, 0.0001)); + + // Verify it doesn't produce the buggy values + expect(telemetry.gpsLocation!.latitude, isNot(equals(buggyLat))); + expect(telemetry.gpsLocation!.latitude, lessThan(90.0)); // Valid range + expect(telemetry.gpsLocation!.latitude, greaterThan(-90.0)); + }, + ); + }); + + group('CayenneLppParser - Other Sensor Tests', () { + test('temperature encoding and decoding', () { + const tempCelsius = 23.5; + + final encoded = CayenneLppParser.createTemperatureData( + tempCelsius, + channel: 1, + ); + + expect(encoded.length, equals(4)); // channel + type + 2 bytes + expect(encoded[0], equals(1)); // channel + expect(encoded[1], equals(MeshCoreConstants.lppTemperatureSensor)); + + final decoded = CayenneLppParser.parse(encoded); + expect(decoded.temperature, closeTo(tempCelsius, 0.1)); + }); + + test('temperature handles negative values', () { + const tempCelsius = -15.3; + + final encoded = CayenneLppParser.createTemperatureData(tempCelsius); + final decoded = CayenneLppParser.parse(encoded); + + expect(decoded.temperature, closeTo(tempCelsius, 0.1)); + }); + + test('battery voltage encoding and decoding', () { + const voltage = 3.85; + + final encoded = CayenneLppParser.createBatteryData(voltage); + + expect(encoded.length, equals(4)); + expect(encoded[1], equals(MeshCoreConstants.lppAnalogInput)); + + final decoded = CayenneLppParser.parse(encoded); + + expect(decoded.batteryMilliVolts, closeTo(3850, 1)); + expect(decoded.batteryPercentage, greaterThan(0)); + expect(decoded.batteryPercentage, lessThanOrEqualTo(100)); + }); + + test('battery percentage calculation', () { + // Test battery curve: 3.0V = 0%, 4.2V = 100% + final testCases = { + 2.8: 0.0, // Below minimum + 3.0: 0.0, // Minimum + 3.6: 50.0, // Middle + 4.2: 100.0, // Maximum + 4.5: 100.0, // Above maximum + }; + + for (final entry in testCases.entries) { + final voltage = entry.key; + final expectedPercent = entry.value; + + final encoded = CayenneLppParser.createBatteryData(voltage); + final decoded = CayenneLppParser.parse(encoded); + + expect( + decoded.batteryPercentage, + closeTo(expectedPercent, 1), + reason: 'Battery ${voltage}V should be ~$expectedPercent%', + ); + } + }); + + test('analog input is recognized as battery', () { + final buffer = ByteData(4); + buffer.setUint8(0, 0); // channel 0 + buffer.setUint8(1, MeshCoreConstants.lppAnalogInput); + buffer.setInt16(2, 385, Endian.big); // 3.85V * 100 + + final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List()); + + expect(decoded.batteryPercentage, isNotNull); + expect(decoded.batteryMilliVolts, closeTo(3850, 1)); + }); + + test('voltage sensor is recognized as battery', () { + final buffer = ByteData(4); + buffer.setUint8(0, 0); + buffer.setUint8(1, MeshCoreConstants.lppVoltageSensor); + buffer.setUint16(2, 385, Endian.big); // 3.85V * 100 + + final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List()); + + expect(decoded.batteryPercentage, isNotNull); + expect(decoded.batteryMilliVolts, closeTo(3850, 1)); + }); + + test('humidity sensor decoding', () { + final buffer = ByteData(3); + buffer.setUint8(0, 0); + buffer.setUint8(1, MeshCoreConstants.lppHumiditySensor); + buffer.setUint8(2, 130); // 65% humidity (130 / 2) + + final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List()); + + expect(decoded.humidity, equals(65.0)); + }); + + test('barometer sensor decoding', () { + final buffer = ByteData(4); + buffer.setUint8(0, 0); + buffer.setUint8(1, MeshCoreConstants.lppBarometer); + buffer.setUint16(2, 10132, Endian.big); // 1013.2 hPa * 10 + + final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List()); + + expect(decoded.pressure, closeTo(1013.2, 0.1)); + }); + + test('accelerometer sensor stores in extra data', () { + final buffer = ByteData(8); + buffer.setUint8(0, 0); + buffer.setUint8(1, MeshCoreConstants.lppAccelerometer); + buffer.setInt16(2, 1000, Endian.big); // x: 1.0 g + buffer.setInt16(4, -500, Endian.big); // y: -0.5 g + buffer.setInt16(6, 2000, Endian.big); // z: 2.0 g + + final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List()); + + expect(decoded.extraSensorData, isNotNull); + final accel = decoded.extraSensorData!['accelerometer_0']; + expect(accel['x'], closeTo(1.0, 0.001)); + expect(accel['y'], closeTo(-0.5, 0.001)); + expect(accel['z'], closeTo(2.0, 0.001)); + }); + + test('extended MeshCore LPP types decode to structured extra data', () { + const lppGenericSensor = 100; + const lppCurrent = 117; + const lppFrequency = 118; + const lppAltitude = 121; + const lppConcentration = 125; + const lppPower = 128; + const lppSpeed = 129; + const lppDistance = 130; + const lppEnergy = 131; + const lppDirection = 132; + const lppUnixTime = 133; + const lppColour = 135; + const lppGust = 137; + const lppDewPoint = 138; + const lppRain = 139; + const lppSwitch = 142; + + final payload = Uint8List.fromList([ + 2, lppGenericSensor, 0x00, 0x00, 0x01, 0x2C, // 300 + 3, lppCurrent, 0x00, 0x0F, // 0.015 A + 4, lppFrequency, 0x00, 0x00, 0x03, 0xE8, // 1000 Hz + 5, lppAltitude, 0x01, 0xF4, // 500 m + 6, lppConcentration, 0x01, 0x9F, // 415 ppm + 7, lppPower, 0x00, 0xFA, // 250 W + 8, lppSpeed, 0x04, 0xD2, // 12.34 m/s + 9, lppDistance, 0x00, 0x00, 0x04, 0xD2, // 1.234 m + 10, lppEnergy, 0x00, 0x00, 0x04, 0xD2, // 1.234 kWh + 11, lppDirection, 0x01, 0x0E, // 270 deg + 12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312 + 13, lppColour, 0xFF, 0x80, 0x40, // #FF8040 + 15, lppGust, 0x01, 0x72, // 3.70 m/s + 16, lppDewPoint, 0x00, 0x14, // 2.0 C + 17, lppRain, 0x00, 0x7B, // 12.3 mm + 14, lppSwitch, 0x01, // on + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.extraSensorData, isNotNull); + expect(decoded.extraSensorData!['generic_sensor_2'], equals(300.0)); + expect(decoded.extraSensorData!['current_3'], closeTo(0.015, 0.0001)); + expect(decoded.extraSensorData!['frequency_4'], equals(1000.0)); + expect(decoded.extraSensorData!['altitude_5'], equals(500.0)); + expect(decoded.extraSensorData!['concentration_6'], equals(415.0)); + expect(decoded.extraSensorData!['power_7'], equals(250.0)); + expect(decoded.extraSensorData!['speed_8'], closeTo(12.34, 0.001)); + expect(decoded.extraSensorData!['distance_9'], closeTo(1.234, 0.0001)); + expect(decoded.extraSensorData!['energy_10'], closeTo(1.234, 0.0001)); + expect(decoded.extraSensorData!['direction_11'], equals(270.0)); + expect(decoded.extraSensorData!['unixtime_12'], equals(1710227456)); + expect( + decoded.extraSensorData!['colour_13'], + equals({'r': 255, 'g': 128, 'b': 64}), + ); + expect(decoded.extraSensorData!['gust_15'], closeTo(3.7, 0.001)); + expect(decoded.extraSensorData!['dew_16'], closeTo(2.0, 0.001)); + expect(decoded.extraSensorData!['rain_17'], closeTo(12.3, 0.001)); + expect(decoded.extraSensorData!['switch_14'], equals(1)); + }); + + test('new MeshCore custom telemetry types decode without truncation', () { + const lppBinaryMoisture = 157; + const lppButtonEvent = 171; + const lppDimmer = 172; + const lppUv = 173; + const lppPm25 = 175; + const lppConductivity = 180; + const lppDuration = 182; + const lppSignedSpeed = 191; + const lppSignedPower = 192; + const lppSignedCurrent = 193; + + final payload = Uint8List.fromList([ + 2, lppBinaryMoisture, 0x01, + 3, lppButtonEvent, 0x04, + 4, lppDimmer, 0xFB, // -5 + 5, lppUv, 0x0A, // 1.0 + 6, lppPm25, 0x00, 0x0C, // 12 + 7, lppConductivity, 0x01, 0xF4, // 500 + 8, lppDuration, 0x00, 0x00, 0x13, 0x88, // 5.0 s + 9, lppSignedSpeed, 0xFF, 0xE1, 0x7B, 0x80, // -2.0 m/s + 10, lppSignedPower, 0xFF, 0xFF, 0xFC, 0x18, // -10.0 W + 11, lppSignedCurrent, 0xFF, 0xFF, 0xFC, 0x18, // -1.0 A + 12, MeshCoreConstants.lppTemperatureSensor, 0x00, 0x76, // 11.8 C + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.extraSensorData!['binary_moisture_2'], equals(1)); + expect(decoded.extraSensorData!['button_event_3'], equals(4)); + expect(decoded.extraSensorData!['dimmer_4'], equals(-5)); + expect(decoded.extraSensorData!['uv_5'], closeTo(1.0, 0.001)); + expect(decoded.extraSensorData!['pm25_6'], equals(12.0)); + expect(decoded.extraSensorData!['conductivity_7'], equals(500.0)); + expect(decoded.extraSensorData!['duration_8'], closeTo(5.0, 0.001)); + expect(decoded.extraSensorData!['signed_speed_9'], closeTo(-2.0, 0.001)); + expect( + decoded.extraSensorData!['signed_power_10'], + closeTo(-10.0, 0.001), + ); + expect( + decoded.extraSensorData!['signed_current_11'], + closeTo(-1.0, 0.001), + ); + expect(decoded.temperature, closeTo(11.8, 0.1)); + }); + + test( + 'percentage battery and non-battery voltage channels are preserved separately', + () { + const lppPercentage = 120; + + final payload = Uint8List.fromList([ + 1, lppPercentage, 66, // battery % + 2, MeshCoreConstants.lppVoltageSensor, 0x01, 0x81, // 3.85 V + 2, MeshCoreConstants.lppTemperatureSensor, 0x00, 0xEB, // 23.5 C + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.batteryPercentage, equals(66.0)); + expect(decoded.extraSensorData!['voltage_2'], closeTo(3.85, 0.001)); + expect(decoded.temperature, closeTo(23.5, 0.1)); + expect(decoded.extraSensorData!['temperature_2'], closeTo(23.5, 0.1)); + }, + ); + + test('weather station payload keeps generic percentage and does not invent battery from ch1 voltage', () { + final payload = Uint8List.fromList([ + 0x01, 0x74, 0x00, 0x00, + 0x02, 0x78, 0x64, + 0x02, 0x73, 0x24, 0x32, + 0x02, 0x65, 0x5A, 0x50, + 0x02, 0x8A, 0xFF, 0xE4, + 0x02, 0x74, 0x01, 0xE0, + 0x02, 0x9D, 0x00, + 0x02, 0x68, 0x76, + 0x02, 0x81, 0x01, 0x4A, + 0x02, 0x89, 0x01, 0xB8, + 0x02, 0x67, 0x00, 0x2D, + 0x02, 0xAD, 0x0A, + 0x02, 0x84, 0x00, 0x56, + 0x02, 0x8B, 0x00, 0xB3, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.batteryPercentage, isNull); + expect(decoded.batteryMilliVolts, isNull); + expect(decoded.temperature, closeTo(4.5, 0.1)); + expect(decoded.humidity, closeTo(59.0, 0.1)); + expect(decoded.pressure, closeTo(926.6, 0.1)); + expect(decoded.extraSensorData!['voltage_1'], closeTo(0.0, 0.001)); + expect(decoded.extraSensorData!['percentage_2'], equals(100.0)); + expect(decoded.extraSensorData!['uv_2'], equals(1.0)); + expect(decoded.extraSensorData!['rain_2'], closeTo(17.9, 0.1)); + expect(decoded.extraSensorData!.containsKey('__source_channel:battery'), isFalse); + }); + + test('unknown sensor type is skipped gracefully', () { + final buffer = ByteData(5); + buffer.setUint8(0, 0); + buffer.setUint8(1, 255); // Unknown type + buffer.setUint8(2, 1); + buffer.setUint8(3, 2); + buffer.setUint8(4, 3); + + // Should not throw, just skip unknown data + expect( + () => CayenneLppParser.parse(buffer.buffer.asUint8List()), + returnsNormally, + ); + }); + + test('multiple sensors in single packet', () { + // Create a combined packet with multiple sensors + final buffer = []; + + // GPS (channel 2) - use channel 2 to avoid battery auto-detection + buffer.add(2); // channel + buffer.add(MeshCoreConstants.lppGps); + // Lat: 46.0569 * 10000 = 460569 = 0x070719 (3 bytes BE) + buffer.addAll([0x07, 0x07, 0x19]); + // Lon: 14.5058 * 10000 = 145058 = 0x0236A2 (3 bytes BE) + buffer.addAll([0x02, 0x36, 0xA2]); + // Alt: 0 (3 bytes BE) + buffer.addAll([0x00, 0x00, 0x00]); + + // Temperature (channel 3) + buffer.add(3); // channel + buffer.add(MeshCoreConstants.lppTemperatureSensor); + buffer.add(0x00); // 23.5°C * 10 = 235 (2 bytes BE) + buffer.add(0xEB); + + // Battery (channel 0 - required for battery detection) + buffer.add(0); // channel + buffer.add(MeshCoreConstants.lppAnalogInput); + buffer.add(0x01); // 3.85V * 100 = 385 (2 bytes BE) + buffer.add(0x81); + + final decoded = CayenneLppParser.parse(Uint8List.fromList(buffer)); + + // All sensors should be decoded + expect(decoded.gpsLocation, isNotNull); + expect(decoded.gpsLocation!.latitude, closeTo(46.0569, 0.0001)); + expect(decoded.temperature, closeTo(23.5, 0.1)); + expect(decoded.batteryMilliVolts, closeTo(3850, 1)); + }); + + test('stops parsing at zero-padded telemetry tail', () { + final payload = Uint8List.fromList([ + 0x01, 0x74, 0x01, 0x5F, // voltage 3.51V + 0x01, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.batteryMilliVolts, closeTo(3510, 1)); + expect(decoded.batteryPercentage, closeTo(42.5, 0.1)); + expect(decoded.gpsLocation, isNotNull); + expect(decoded.gpsLocation!.latitude, 0.0); + expect(decoded.gpsLocation!.longitude, 0.0); + expect(decoded.extraSensorData?['digital_input_0'], isNull); + }); + + test('empty data returns empty telemetry', () { + final empty = Uint8List(0); + final decoded = CayenneLppParser.parse(empty); + + expect(decoded.gpsLocation, isNull); + expect(decoded.batteryPercentage, isNull); + expect(decoded.temperature, isNull); + expect(decoded.extraSensorData, isNull); + expect(decoded.timestamp, isNotNull); // Timestamp is always set + }); + + test('timestamp is set to parse time', () { + final before = DateTime.now(); + final data = CayenneLppParser.createTemperatureData(20.0); + final decoded = CayenneLppParser.parse(data); + final after = DateTime.now(); + + expect( + decoded.timestamp.isAfter(before) || + decoded.timestamp.isAtSameMomentAs(before), + isTrue, + ); + expect( + decoded.timestamp.isBefore(after) || + decoded.timestamp.isAtSameMomentAs(after), + isTrue, + ); + }); + }); + + group('CayenneLppParser - ContactTelemetry Properties', () { + test('isRecent returns true for fresh telemetry', () { + final data = CayenneLppParser.createTemperatureData(20.0); + final telemetry = CayenneLppParser.parse(data); + + expect(telemetry.isRecent, isTrue); + }); + + test('battery status helpers work correctly', () { + final lowBattery = ContactTelemetry( + batteryPercentage: 15.0, + timestamp: DateTime.now(), + ); + expect(lowBattery.isLowBattery, isTrue); + expect(lowBattery.batteryStatus, equals('low')); + + final criticalBattery = ContactTelemetry( + batteryPercentage: 5.0, + timestamp: DateTime.now(), + ); + expect(criticalBattery.isCriticalBattery, isTrue); + + final goodBattery = ContactTelemetry( + batteryPercentage: 75.0, + timestamp: DateTime.now(), + ); + expect(goodBattery.isLowBattery, isFalse); + expect(goodBattery.batteryStatus, equals('good')); + }); + + test('copyWith creates modified copy', () { + final original = ContactTelemetry( + gpsLocation: LatLng(1, 2), + batteryPercentage: 50.0, + temperature: 20.0, + timestamp: DateTime.now(), + ); + + final modified = original.copyWith(temperature: 25.0); + + expect(modified.temperature, equals(25.0)); + expect(modified.batteryPercentage, equals(50.0)); // Unchanged + expect(modified.gpsLocation, equals(original.gpsLocation)); // Unchanged + }); + + test('toString provides readable output', () { + final telemetry = ContactTelemetry( + gpsLocation: LatLng(46.0569, 14.5058), + batteryPercentage: 75.0, + temperature: 23.5, + timestamp: DateTime.now(), + ); + + final str = telemetry.toString(); + expect(str, contains('ContactTelemetry')); + expect(str, contains('46.0569')); + expect(str, contains('75')); + expect(str, contains('23.5')); + }); + }); +} diff --git a/test/services/compass_support_test.dart b/test/services/compass_support_test.dart new file mode 100644 index 0000000..2591c7b --- /dev/null +++ b/test/services/compass_support_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/services/compass_support.dart'; + +void main() { + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + test('reports compass support only on mobile targets', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(CompassSupport.isAvailable, isTrue); + + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + expect(CompassSupport.isAvailable, isTrue); + + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + expect(CompassSupport.isAvailable, isFalse); + + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + expect(CompassSupport.isAvailable, isFalse); + }); +} diff --git a/test/services/contact_route_resolver_test.dart b/test/services/contact_route_resolver_test.dart new file mode 100644 index 0000000..8cd5587 --- /dev/null +++ b/test/services/contact_route_resolver_test.dart @@ -0,0 +1,143 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/services/contact_route_resolver.dart'; + +void main() { + test('prefers known repeater route when available', () { + final recipient = _contact( + name: 'Target', + type: ContactType.chat, + seed: 90, + lat: 46.20, + lon: 14.70, + ); + final routedRepeater = _contact( + name: 'Routed', + type: ContactType.repeater, + seed: 10, + lat: 46.15, + lon: 14.60, + signedPathLen: ContactRouteCodec.toSignedDescriptor(1), + outPath: Uint8List.fromList([0xAA]), + ); + + final plan = ContactRouteResolver.resolveAutomaticRoute( + senderLocation: const LatLng(46.00, 14.50), + recipient: recipient, + availableContacts: [routedRepeater], + hashSize: 1, + ); + + expect(plan, isNotNull); + expect(plan!.tokens, [ + 'AA', + routedRepeater.publicKeyHex.substring(0, 2).toUpperCase(), + ]); + }); + + test('falls back to location-only repeaters', () { + final recipient = _contact( + name: 'Target', + type: ContactType.chat, + seed: 90, + lat: 46.20, + lon: 14.70, + ); + final nearRepeater = _contact( + name: 'Near', + type: ContactType.repeater, + seed: 10, + lat: 46.08, + lon: 14.55, + ); + final farRepeater = _contact( + name: 'Far', + type: ContactType.repeater, + seed: 11, + lat: 46.40, + lon: 15.10, + ); + + final plan = ContactRouteResolver.resolveAutomaticRoute( + senderLocation: const LatLng(46.00, 14.50), + recipient: recipient, + availableContacts: [nearRepeater, farRepeater], + hashSize: 1, + ); + + expect(plan, isNotNull); + expect(plan!.selectedContacts.first.displayName, 'Near'); + }); + + test('for known routes prefers anchor closest to the chain end', () { + final recipient = _contact( + name: 'Target', + type: ContactType.chat, + seed: 90, + lat: 46.20, + lon: 14.70, + ); + final chainHop = _contact( + name: 'Chain Hop', + type: ContactType.repeater, + seed: 0xAA, + lat: 46.01, + lon: 14.51, + ); + final nearAnchor = _contact( + name: 'Near Anchor', + type: ContactType.repeater, + seed: 10, + lat: 46.03, + lon: 14.53, + signedPathLen: ContactRouteCodec.toSignedDescriptor(1), + outPath: Uint8List.fromList([0xAA]), + ); + final farAnchor = _contact( + name: 'Far Anchor', + type: ContactType.repeater, + seed: 11, + lat: 46.18, + lon: 14.68, + signedPathLen: ContactRouteCodec.toSignedDescriptor(1), + outPath: Uint8List.fromList([0xAA]), + ); + + final plan = ContactRouteResolver.resolveAutomaticRoute( + senderLocation: const LatLng(46.00, 14.50), + recipient: recipient, + availableContacts: [chainHop, nearAnchor, farAnchor], + hashSize: 1, + ); + + expect(plan, isNotNull); + expect(plan!.selectedContacts.last.displayName, 'Near Anchor'); + }); +} + +Contact _contact({ + required String name, + required ContactType type, + required int seed, + required double lat, + required double lon, + int signedPathLen = -1, + Uint8List? outPath, +}) { + final publicKey = Uint8List(32)..fillRange(0, 32, seed); + return Contact( + publicKey: publicKey, + type: type, + flags: 0, + outPathLen: signedPathLen, + outPath: outPath ?? Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (lat * 1e6).round(), + advLon: (lon * 1e6).round(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); +} diff --git a/test/services/contact_storage_service_test.dart b/test/services/contact_storage_service_test.dart new file mode 100644 index 0000000..ebfe29e --- /dev/null +++ b/test/services/contact_storage_service_test.dart @@ -0,0 +1,56 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/services/contact_storage_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Contact createContact({required Uint8List key, required String name}) { + return Contact( + publicKey: key, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (46.0569 * 1e6).round(), + advLon: (14.5058 * 1e6).round(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('keeps default and custom profile contact storage isolated', () async { + final storage = ContactStorageService(); + final defaultContact = createContact( + key: Uint8List.fromList(List.filled(32, 1)), + name: 'Default Contact', + ); + final customContact = createContact( + key: Uint8List.fromList(List.filled(32, 2)), + name: 'Custom Contact', + ); + + await storage.saveContacts([defaultContact]); + await storage.saveContacts([customContact], namespace: 'alpha'); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('stored_contacts'), isNotNull); + expect(prefs.getString('profile.alpha.stored_contacts'), isNotNull); + + final defaultContacts = await storage.loadContacts(); + final customContacts = await storage.loadContacts(namespace: 'alpha'); + + expect(defaultContacts.single.advName, defaultContact.advName); + expect(customContacts.single.advName, customContact.advName); + expect(defaultContacts.single.publicKey, defaultContact.publicKey); + expect(customContacts.single.publicKey, customContact.publicKey); + }); +} diff --git a/test/services/live_traffic_summary_test.dart b/test/services/live_traffic_summary_test.dart new file mode 100644 index 0000000..9437d31 --- /dev/null +++ b/test/services/live_traffic_summary_test.dart @@ -0,0 +1,213 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/ble_packet_log.dart'; +import 'package:meshcore_sar_app/services/live_traffic_summary.dart'; + +BlePacketLog _log({ + required DateTime timestamp, + required PacketDirection direction, + required List rawData, + int? responseCode, + double? snrDb, + int? rssiDbm, +}) { + return BlePacketLog( + timestamp: timestamp, + rawData: Uint8List.fromList(rawData), + direction: direction, + responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first), + logRxDataInfo: snrDb == null && rssiDbm == null + ? null + : LogRxDataInfo( + entropy: 0, + isLikelyEncrypted: false, + snrDb: snrDb, + rssiDbm: rssiDbm, + ), + ); +} + +List _multiHopRaw({ + required List hops, + int payloadType = 0x01, + int hashSize = 2, +}) { + final hopCount = hops.length ~/ hashSize; + final pathDescriptor = ((hashSize - 1) << 6) | hopCount; + return [ + 0x88, + 0x00, + 0x00, + payloadType << 2, + 0x00, + 0x00, + 0x00, + 0x00, + pathDescriptor, + ...hops, + ]; +} + +void main() { + group('LiveTrafficSummary', () { + test('uses only the rolling 60-second window', () { + final now = DateTime(2026, 3, 12, 12, 0, 0); + final snapshot = LiveTrafficSummary.fromLogs([ + _log( + timestamp: now.subtract(const Duration(seconds: 61)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 20)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.tx, + rawData: [0x01, 0x02], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 5)), + direction: PacketDirection.rx, + rawData: [0x01, 0x02], + responseCode: 0x01, + ), + ], now: now); + + expect(snapshot.totalCount, 1); + expect(snapshot.rxCount, 1); + expect(snapshot.txCount, 0); + expect(snapshot.packetsPerMinute, 1); + }); + + test('aggregates RSSI, SNR, and multi-hop route metrics', () { + final now = DateTime(2026, 3, 12, 12, 0, 0); + final snapshot = LiveTrafficSummary.fromLogs([ + _log( + timestamp: now.subtract(const Duration(seconds: 30)), + direction: PacketDirection.rx, + rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]), + responseCode: 0x88, + snrDb: 12.0, + rssiDbm: -84, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 15)), + direction: PacketDirection.rx, + rawData: _multiHopRaw(hops: [0xDE, 0xAD, 0xBE, 0xEF]), + responseCode: 0x88, + snrDb: 6.0, + rssiDbm: -90, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 5)), + direction: PacketDirection.tx, + rawData: [0x03, 0x04], + responseCode: 0x88, + ), + ], now: now); + + expect(snapshot.latestRssiDbm, -90); + expect(snapshot.latestSnrDb, 6.0); + expect(snapshot.avgRssiDbm, closeTo(-87.0, 0.01)); + expect(snapshot.avgSnrDb, closeTo(9.0, 0.01)); + expect(snapshot.multiHopCount, 2); + expect(snapshot.avgHopCount, closeTo(2.5, 0.01)); + expect(snapshot.busyness, LiveTrafficBusyness.quiet); + }); + + test('normalizes packet rate for windows longer than one minute', () { + final now = DateTime(2026, 3, 12, 12, 0, 0); + final snapshot = LiveTrafficSummary.fromLogs( + List.generate( + 24, + (index) => _log( + timestamp: now.subtract(Duration(seconds: index * 10)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + ), + now: now, + window: const Duration(minutes: 5), + ); + + expect(snapshot.totalCount, 24); + expect(snapshot.windowDuration, const Duration(minutes: 5)); + expect(snapshot.packetsPerMinute, 5); + }); + + test('supports clearing the live view without mutating source logs', () { + final now = DateTime(2026, 3, 12, 12, 0, 0); + final clearAt = now.subtract(const Duration(seconds: 8)); + final snapshot = LiveTrafficSummary.fromLogs( + [ + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 4)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + ], + now: now, + clearedAt: clearAt, + ); + + expect(snapshot.totalCount, 1); + expect(snapshot.visibleEntries, hasLength(1)); + }); + + test('counts only RX data packets for device totals', () { + final now = DateTime(2026, 3, 12, 12, 0, 0); + + expect( + LiveTrafficSummary.countRxDataLogs([ + _log( + timestamp: now.subtract(const Duration(seconds: 30)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 20)), + direction: PacketDirection.tx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.rx, + rawData: [0x05, 0x01, 0x02], + responseCode: 0x05, + ), + ]), + 1, + ); + }); + + test('exposes detailed descriptions for known packet types', () { + final returnedPath = LiveTrafficEntry.payloadTypeDetails(0x08); + final control = LiveTrafficEntry.payloadTypeDetails(0x0B); + + expect(returnedPath.title, 'FLOOD RETURNED_PATH'); + expect( + returnedPath.description, + contains('stores that returned path as the peer\'s direct out-path'), + ); + expect(control.label, 'Control packet'); + expect(control.description, contains('discovery request and response')); + }); + }); +} diff --git a/test/services/location_tracking_service_test.dart b/test/services/location_tracking_service_test.dart new file mode 100644 index 0000000..3eac7c2 --- /dev/null +++ b/test/services/location_tracking_service_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:meshcore_sar_app/services/location_tracking_service.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; + +void main() { + final service = LocationTrackingService(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: false, + activeProfileId: 'default', + ); + service.fastLocationUpdatesEnabled = false; + service.fastLocationMovementThresholdMeters = 10.0; + service.fastLocationActiveCadenceSeconds = 60; + service.fastLocationChannelIdx = null; + }); + + test('loads conservative fast location defaults', () async { + await service.loadSettings(); + + expect(service.fastLocationMovementThresholdMeters, 10.0); + expect(service.fastLocationActiveCadenceSeconds, 60); + }); + + test('persists and restores fast location channel idx', () async { + await service.updateFastLocationChannelIdx(3); + + service.fastLocationChannelIdx = null; + await service.loadSettings(); + + expect(service.fastLocationChannelIdx, 3); + }); + + test('clears persisted fast location channel idx when unset', () async { + await service.updateFastLocationChannelIdx(7); + await service.updateFastLocationChannelIdx(null); + + service.fastLocationChannelIdx = 99; + await service.loadSettings(); + + expect(service.fastLocationChannelIdx, isNull); + }); + + test('clamps fast location settings to conservative limits', () async { + await service.updateFastLocationMovementThreshold(3); + await service.updateFastLocationActiveCadenceSeconds(45); + + expect(service.fastLocationMovementThresholdMeters, 10.0); + expect(service.fastLocationActiveCadenceSeconds, 60); + }); +} diff --git a/test/services/map_marker_service_test.dart b/test/services/map_marker_service_test.dart new file mode 100644 index 0000000..344f02f --- /dev/null +++ b/test/services/map_marker_service_test.dart @@ -0,0 +1,63 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/services/map_marker_service.dart'; +import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart'; + +void main() { + Contact buildContact({ + required String name, + required ContactType type, + required int advLat, + required int advLon, + }) { + return Contact( + publicKey: Uint8List(32), + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(0), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch, + advLat: advLat, + advLon: advLon, + lastMod: DateTime.now().millisecondsSinceEpoch, + ); + } + + testWidgets('contact map markers render shared contact avatars', (tester) async { + final service = MapMarkerService(); + final contact = buildContact( + name: 'John Smith', + type: ContactType.chat, + advLat: (46.0569 * 1e6).round(), + advLon: (14.5058 * 1e6).round(), + ); + + late Widget markerChild; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final markers = service.generateContactMarkers( + contacts: [contact], + context: context, + ); + markerChild = markers.single.child; + return const SizedBox.shrink(); + }, + ), + ), + ); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: Center(child: markerChild))), + ); + + expect(find.byType(ContactAvatar), findsOneWidget); + expect(find.text('JS'), findsOneWidget); + }); +} diff --git a/test/services/mesh_map_nodes_service_test.dart b/test/services/mesh_map_nodes_service_test.dart new file mode 100644 index 0000000..14c40f7 --- /dev/null +++ b/test/services/mesh_map_nodes_service_test.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await MeshMapNodesService.clearCache(); + }); + + test('fetchNodes persists nodes and cached lookup works offline', () async { + final client = MockClient((request) async { + expect( + request.url.toString(), + 'https://api.meshcore.nz/api/v1/map/nodes', + ); + return http.Response( + jsonEncode({ + 'nodes': [ + { + 'type': 1, + 'name': 'Alpha', + 'public_key': 'aa11bb22', + 'latitude': 46.05, + 'longitude': 14.50, + 'updated_at': 123456, + }, + ], + }), + 200, + ); + }); + + final nodes = await MeshMapNodesService.fetchNodes(client: client); + final cached = await MeshMapNodesService.fetchNodes(allowNetwork: false); + + expect(nodes, hasLength(1)); + expect(cached, hasLength(1)); + expect(cached.first.name, 'Alpha'); + expect(await MeshMapNodesService.hasFreshCache(), isTrue); + }); + + test('loadCachedNodes ignores stale persisted cache', () async { + SharedPreferences.setMockInitialValues({ + 'mesh_map_nodes_cache_v1': jsonEncode([ + { + 'type': 1, + 'name': 'Old node', + 'public_key': 'bb22cc33', + 'latitude': 46.05, + 'longitude': 14.50, + 'updated_at': 123456, + }, + ]), + 'mesh_map_nodes_cache_timestamp_v1': DateTime.now() + .subtract(const Duration(hours: 25)) + .millisecondsSinceEpoch, + }); + + final nodes = await MeshMapNodesService.loadCachedNodes(); + + expect(nodes, isEmpty); + expect(await MeshMapNodesService.hasFreshCache(), isFalse); + }); + + test('clearCache removes persisted online node cache', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'nodes': [ + { + 'type': 1, + 'name': 'Alpha', + 'public_key': 'aa11bb22', + 'latitude': 46.05, + 'longitude': 14.50, + 'updated_at': 123456, + }, + ], + }), + 200, + ), + ); + + await MeshMapNodesService.fetchNodes(client: client); + await MeshMapNodesService.clearCache(); + + expect(await MeshMapNodesService.loadCachedNodes(), isEmpty); + expect(await MeshMapNodesService.cachedAt(), isNull); + }); + + test('fetchNodes ignores nodes with invalid coordinates', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'nodes': [ + { + 'type': 1, + 'name': 'Broken', + 'public_key': 'bad123', + 'latitude': 3213.0, + 'longitude': 14.50, + 'updated_at': 123456, + }, + { + 'type': 1, + 'name': 'Alpha', + 'public_key': 'aa11bb22', + 'latitude': 46.05, + 'longitude': 14.50, + 'updated_at': 123456, + }, + ], + }), + 200, + ), + ); + + final nodes = await MeshMapNodesService.fetchNodes(client: client); + + expect(nodes, hasLength(1)); + expect(nodes.first.name, 'Alpha'); + }); +} diff --git a/test/services/message_storage_service_test.dart b/test/services/message_storage_service_test.dart new file mode 100644 index 0000000..e9bc710 --- /dev/null +++ b/test/services/message_storage_service_test.dart @@ -0,0 +1,192 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_client/meshcore_client.dart'; +import 'package:meshcore_sar_app/models/message_reception_details.dart'; +import 'package:meshcore_sar_app/services/message_storage_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final originalDebugPrint = debugPrint; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + tearDown(() { + debugPrint = originalDebugPrint; + }); + + test( + 'retains path bytes for stored unread message when reception sidecar is missing', + () async { + final storage = MessageStorageService(); + final message = Message( + id: 'msg-1', + messageType: MessageType.contact, + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + pathLen: 2, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Unread message', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + isRead: false, + ); + + await storage.saveMessages( + [message], + messageReceptionDetails: { + message.id: MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600), + pathBytes: const [0xAA, 0xBB, 0xCC], + ), + }, + ); + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('stored_message_reception_details'); + + final restoredDetails = await storage.loadMessageReceptionDetails(); + final restoredMessages = await storage.loadMessages(); + + expect(restoredDetails.keys, contains(message.id)); + expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]); + expect(restoredMessages.single.pathBytes, [0xAA, 0xBB, 0xCC]); + }, + ); + + test('retains embedded reception details when sidecar is missing', () async { + final storage = MessageStorageService(); + final message = Message( + id: 'msg-2', + messageType: MessageType.channel, + senderPublicKeyPrefix: Uint8List.fromList([6, 5, 4, 3, 2, 1]), + channelIdx: 2, + pathLen: 3, + textType: MessageTextType.plain, + senderTimestamp: 1700000100, + text: 'Room update', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000100500), + isRead: false, + ); + + await storage.saveMessages( + [message], + messageReceptionDetails: { + message.id: MessageReceptionDetails( + capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000100600), + packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000100400), + rssiDbm: -91, + snrDb: 7.25, + pathBytes: const [0xAA, 0xBB, 0xCC, 0xDD], + senderToReceiptMs: 1200, + estimatedTransmitMs: 800, + postTransmitDelayMs: 400, + receivedCopies: 3, + ), + }, + ); + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('stored_message_reception_details'); + + final restoredDetails = await storage.loadMessageReceptionDetails(); + final restored = restoredDetails[message.id]; + + expect(restored, isNotNull); + expect(restored!.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]); + expect(restored.rssiDbm, -91); + expect(restored.snrDb, 7.25); + expect(restored.senderToReceiptMs, 1200); + expect(restored.estimatedTransmitMs, 800); + expect(restored.postTransmitDelayMs, 400); + expect(restored.receivedCopies, 3); + expect( + restored.packetLoggedAt, + DateTime.fromMillisecondsSinceEpoch(1700000100400), + ); + }); + + test('persists removed SAR marker IDs', () async { + final storage = MessageStorageService(); + + await storage.saveRemovedSarMarkerIds({'sar-2', 'sar-1'}); + + final restored = await storage.loadRemovedSarMarkerIds(); + + expect(restored, equals({'sar-1', 'sar-2'})); + }); + + test('keeps default and custom profile message storage isolated', () async { + final storage = MessageStorageService(); + final defaultMessage = Message( + id: 'default-msg', + messageType: MessageType.channel, + senderPublicKeyPrefix: Uint8List.fromList([1, 1, 1, 1, 1, 1]), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700001000, + text: 'Default profile', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700001000500), + isRead: true, + ); + final customMessage = Message( + id: 'custom-msg', + messageType: MessageType.channel, + senderPublicKeyPrefix: Uint8List.fromList([2, 2, 2, 2, 2, 2]), + channelIdx: 1, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700002000, + text: 'Custom profile', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700002000500), + isRead: false, + ); + + await storage.saveMessages([defaultMessage]); + await storage.saveMessages([customMessage], namespace: 'alpha'); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('stored_messages'), isNotNull); + expect(prefs.getString('profile.alpha.stored_messages'), isNotNull); + + final defaultMessages = await storage.loadMessages(); + final customMessages = await storage.loadMessages(namespace: 'alpha'); + + expect(defaultMessages.single.id, defaultMessage.id); + expect(defaultMessages.single.text, defaultMessage.text); + expect(customMessages.single.id, customMessage.id); + expect(customMessages.single.text, customMessage.text); + }); + + test('skips unchanged message snapshots', () async { + final storage = MessageStorageService(); + final logs = []; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) { + logs.add(message); + } + }; + final message = Message( + id: 'msg-stable', + messageType: MessageType.channel, + senderPublicKeyPrefix: Uint8List.fromList([3, 3, 3, 3, 3, 3]), + channelIdx: 3, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700003000, + text: 'Stable snapshot', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700003000500), + isRead: true, + ); + + await storage.saveMessages([message]); + await storage.saveMessages([message]); + + expect( + logs.where((log) => log.contains('Saved 1 messages to storage')), + hasLength(1), + ); + }); +} diff --git a/test/services/messaging_route_preferences_test.dart b/test/services/messaging_route_preferences_test.dart new file mode 100644 index 0000000..28a5459 --- /dev/null +++ b/test/services/messaging_route_preferences_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:meshcore_sar_app/services/messaging_route_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('route preference defaults are clear-path disabled and fallback enabled', () async { + expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse); + expect( + await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), + isTrue, + ); + }); + + test('route preferences persist changes', () async { + await MessagingRoutePreferences.setClearPathOnMaxRetry(true); + await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false); + + expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue); + expect( + await MessagingRoutePreferences.getNearestRelayFallbackEnabled(), + isFalse, + ); + }); + + test('legacy auto route rotation preference is removed during cleanup', () async { + SharedPreferences.setMockInitialValues({ + 'messaging_auto_route_rotation_enabled': true, + }); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('messaging_auto_route_rotation_enabled'), isTrue); + + await MessagingRoutePreferences.cleanupLegacySettings(); + + expect( + prefs.containsKey('messaging_auto_route_rotation_enabled'), + isFalse, + ); + }); +} diff --git a/test/services/nearest_router_selector_test.dart b/test/services/nearest_router_selector_test.dart new file mode 100644 index 0000000..ba900a1 --- /dev/null +++ b/test/services/nearest_router_selector_test.dart @@ -0,0 +1,115 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; + +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/services/nearest_router_selector.dart'; + +Contact _buildRepeater({ + required int seed, + required String name, + required double latitude, + required double longitude, + required int lastAdvert, + int outPathLen = -1, +}) { + return Contact( + publicKey: Uint8List.fromList(List.generate(32, (i) => i + seed)), + type: ContactType.repeater, + flags: 0, + outPathLen: outPathLen, + outPath: Uint8List(0), + advName: name, + lastAdvert: lastAdvert, + advLat: (latitude * 1e6).round(), + advLon: (longitude * 1e6).round(), + lastMod: lastAdvert, + ); +} + +Position _position(double latitude, double longitude) { + return Position( + latitude: latitude, + longitude: longitude, + timestamp: DateTime.now(), + accuracy: 1, + altitude: 0, + altitudeAccuracy: 1, + heading: 0, + headingAccuracy: 1, + speed: 0, + speedAccuracy: 0, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('selector chooses nearest eligible repeater', () { + final selector = NearestRouterSelector(); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final recipient = _buildRepeater( + seed: 90, + name: 'Recipient', + latitude: 46.05, + longitude: 14.50, + lastAdvert: now, + ).copyWith(type: ContactType.chat); + + final selected = selector.select( + senderPosition: _position(46.0569, 14.5058), + repeaters: [ + _buildRepeater( + seed: 1, + name: 'Far', + latitude: 46.10, + longitude: 14.60, + lastAdvert: now, + outPathLen: 1, + ), + _buildRepeater( + seed: 2, + name: 'Near', + latitude: 46.0570, + longitude: 14.5060, + lastAdvert: now - 5, + outPathLen: 1, + ), + ], + recipient: recipient, + ); + + expect(selected?.advName, 'Near'); + }); + + test('selector skips stale repeaters', () { + final selector = NearestRouterSelector(); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final staleAdvert = now - (11 * 60); + final recipient = _buildRepeater( + seed: 91, + name: 'Recipient', + latitude: 46.05, + longitude: 14.50, + lastAdvert: now, + ).copyWith(type: ContactType.chat); + + final selected = selector.select( + senderPosition: _position(46.0569, 14.5058), + repeaters: [ + _buildRepeater( + seed: 3, + name: 'Stale', + latitude: 46.0570, + longitude: 14.5060, + lastAdvert: staleAdvert, + outPathLen: 1, + ), + ], + recipient: recipient, + ); + + expect(selected, isNull); + }); +} diff --git a/test/services/notification_service_test.dart b/test/services/notification_service_test.dart new file mode 100644 index 0000000..cfd45c2 --- /dev/null +++ b/test/services/notification_service_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/services/notification_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('discovery notification preference persists independently', () async { + final service = NotificationService(); + + await service.setMessageNotificationsEnabled(true); + await service.setDiscoveryNotificationsEnabled(false); + + final prefs = await SharedPreferences.getInstance(); + + expect(service.messageNotificationsEnabled, isTrue); + expect(service.discoveryNotificationsEnabled, isFalse); + expect(prefs.getBool('notifications_messages_enabled'), isTrue); + expect(prefs.getBool('notifications_discovery_enabled'), isFalse); + }); +} diff --git a/test/services/offline_map_caching_provider_test.dart b/test/services/offline_map_caching_provider_test.dart new file mode 100644 index 0000000..5a3a935 --- /dev/null +++ b/test/services/offline_map_caching_provider_test.dart @@ -0,0 +1,110 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/services/offline_map_caching_provider.dart'; +import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + late Directory tempDir; + final cache = OfflineTileCacheService.instance; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('offline_map_provider_test_'); + await cache.resetForTesting(); + cache.setBaseDirForTesting('${tempDir.path}/tiles'); + cache.setDatabasePathForTesting('${tempDir.path}/tiles.db'); + }); + + tearDown(() async { + await cache.resetForTesting(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('parses query-style tile URLs used by Google layers', () { + final coords = OfflineMapCachingProvider.parseTileUrlForTesting( + 'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13', + ); + + expect(coords, isNotNull); + expect(coords!.z, 13); + expect(coords.x, 4312); + expect(coords.y, 2810); + expect( + OfflineMapCachingProvider.extractUrlTemplateForTesting( + 'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13', + ), + 'http://mt0.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}', + ); + }); + + test('parses Esri tile URLs and preserves y/x order in template', () { + final coords = OfflineMapCachingProvider.parseTileUrlForTesting( + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267', + ); + + expect(coords, isNotNull); + expect(coords!.z, 9); + expect(coords.x, 267); + expect(coords.y, 173); + expect( + OfflineMapCachingProvider.extractUrlTemplateForTesting( + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267', + ), + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + ); + }); + + test('returns cached raw bytes directly', () async { + const url = + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267'; + final template = OfflineMapCachingProvider.extractUrlTemplateForTesting(url); + final styleHash = cache.styleHashFromUrl(template); + final bytes = Uint8List.fromList([1, 2, 3]); + + await cache.saveStyleMeta( + styleHash, + displayName: 'Esri', + urlTemplate: template, + ); + await cache.putTile( + styleHash, + 9, + 267, + 173, + bytes, + contentType: 'image/jpeg', + sourceUrl: url, + ); + + final provider = OfflineMapCachingProvider(_NullMapCachingProvider()); + final tile = await provider.getTile(url); + + expect(tile, isNotNull); + expect(tile!.bytes, bytes); + }); +} + +class _NullMapCachingProvider implements MapCachingProvider { + @override + bool get isSupported => true; + + @override + Future getTile(String url) async => null; + + @override + Future putTile({ + required String url, + required CachedMapTileMetadata metadata, + Uint8List? bytes, + }) async {} +} diff --git a/test/services/offline_tile_cache_service_test.dart b/test/services/offline_tile_cache_service_test.dart new file mode 100644 index 0000000..ab07cfd --- /dev/null +++ b/test/services/offline_tile_cache_service_test.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + late Directory tempDir; + final cache = OfflineTileCacheService.instance; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('offline_tile_cache_test_'); + await cache.resetForTesting(); + cache.setBaseDirForTesting('${tempDir.path}/tiles'); + cache.setDatabasePathForTesting('${tempDir.path}/tiles.db'); + }); + + tearDown(() async { + await cache.resetForTesting(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('stores original bytes and content type without conversion', () async { + const styleHash = 'abcdef123456'; + final bytes = Uint8List.fromList([1, 2, 3, 4]); + + await cache.saveStyleMeta( + styleHash, + displayName: 'Test layer', + urlTemplate: 'https://example.com/{z}/{x}/{y}.png', + ); + await cache.putTile( + styleHash, + 8, + 12, + 34, + bytes, + contentType: 'image/png; charset=binary', + sourceUrl: 'https://example.com/8/12/34.png', + ); + + final tile = await cache.getTileData(styleHash, 8, 12, 34); + expect(tile, isNotNull); + expect(tile!.bytes, bytes); + expect(tile.contentType, 'image/png'); + expect(await cache.hasTile(styleHash, 8, 12, 34), isTrue); + expect(await cache.getCacheSize(), bytes.length); + + final styles = await cache.listStylesDetailed(); + expect(styles, hasLength(1)); + expect(styles.single.tileCount, 1); + expect(styles.single.sizeBytes, bytes.length); + }); + + test('hydrates existing disk cache into sqlite on first access', () async { + const styleHash = 'fedcba654321'; + final base = Directory('${tempDir.path}/tiles/$styleHash/9/13'); + await base.create(recursive: true); + final file = File('${base.path}/42.avif'); + await file.writeAsBytes([9, 8, 7], flush: true); + await File('${tempDir.path}/tiles/$styleHash/meta.json').writeAsString( + jsonEncode({ + 'displayName': 'Legacy layer', + 'urlTemplate': 'https://example.com/{z}/{x}/{y}.avif', + }), + flush: true, + ); + + final tiles = await cache.listTilesForStyle(styleHash); + expect(tiles, hasLength(1)); + expect(tiles.single.z, 9); + expect(tiles.single.x, 13); + expect(tiles.single.y, 42); + + final tile = await cache.getTileData(styleHash, 9, 13, 42); + expect(tile, isNotNull); + expect(tile!.bytes, Uint8List.fromList([9, 8, 7])); + expect(tile.contentType, 'image/avif'); + + final styles = await cache.listStylesDetailed(); + expect(styles.single.displayName, 'Legacy layer'); + expect(styles.single.tileCount, 1); + expect(styles.single.sizeBytes, 3); + }); + + test('deletes style metadata and files', () async { + const styleHash = 'aabbccddeeff'; + + await cache.saveStyleMeta( + styleHash, + displayName: 'Delete me', + urlTemplate: 'https://example.com/{z}/{x}/{y}.jpg', + ); + await cache.putTile( + styleHash, + 1, + 2, + 3, + Uint8List.fromList([5, 6]), + contentType: 'image/jpeg', + ); + + expect(await cache.getCacheSize(), 2); + await cache.deleteStyle(styleHash); + expect(await cache.getCacheSize(), 0); + expect(await cache.getTileData(styleHash, 1, 2, 3), isNull); + expect(await Directory('${tempDir.path}/tiles/$styleHash').exists(), isFalse); + }); +} diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart new file mode 100644 index 0000000..12e5b21 --- /dev/null +++ b/test/services/path_history_service_test.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/path_selection.dart'; +import 'package:meshcore_sar_app/services/path_history_service.dart'; + +Contact _buildContact({ + required int seed, + List pathBytes = const [], + int hopCount = 0, + int hashSize = 1, +}) { + final encoded = pathBytes.isEmpty ? -1 : ((hashSize - 1) << 6) | (hopCount & 0x3F); + final outPath = Uint8List(ContactRouteCodec.maxPathBytes); + if (pathBytes.isNotEmpty) { + outPath.setRange(0, pathBytes.length, pathBytes); + } + + return Contact( + publicKey: Uint8List.fromList(List.generate(32, (i) => i + seed)), + type: ContactType.chat, + flags: 0, + outPathLen: encoded == -1 ? -1 : ContactRouteCodec.toSignedDescriptor(encoded), + outPath: encoded == -1 ? Uint8List(0) : outPath, + advName: 'Contact $seed', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('manual route override persists across reloads', () async { + final contact = _buildContact(seed: 1); + final service = PathHistoryService(); + await service.initialize(); + await service.setManualSelectionFor( + contact.publicKeyHex, + PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList([0xAA, 0xBB]), + hopCount: 2, + hashSize: 1, + ), + ); + + final reloaded = PathHistoryService(); + final selection = await reloaded.getManualSelectionForContact(contact); + + expect(selection, isNotNull); + expect(selection!.mode, PathSelectionMode.directCurrent); + expect(selection.canonicalPath, 'AA,BB'); + }); + + test('selection uses stored manual route before contact route', () async { + final contact = _buildContact( + seed: 2, + pathBytes: const [0x11, 0x22], + hopCount: 2, + hashSize: 1, + ); + final service = PathHistoryService(); + await service.initialize(); + await service.setManualSelectionFor( + contact.publicKeyHex, + PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList([0xAA, 0xBB]), + hopCount: 2, + hashSize: 1, + ), + ); + + final selection = await service.getSelectionForContact(contact); + + expect(selection.mode, PathSelectionMode.directCurrent); + expect(selection.canonicalPath, 'AA,BB'); + }); + + test('selection falls back to the current contact route', () async { + final contact = _buildContact( + seed: 3, + pathBytes: const [0x10, 0x20, 0x30], + hopCount: 1, + hashSize: 3, + ); + final service = PathHistoryService(); + + final selection = await service.getSelectionForContact(contact); + + expect(selection.mode, PathSelectionMode.directCurrent); + expect(selection.canonicalPath, '102030'); + expect(selection.hashSize, 3); + expect(selection.hopCount, 1); + }); + + test('selection falls back to flood when no route exists', () async { + final contact = _buildContact(seed: 4); + final service = PathHistoryService(); + + final selection = await service.getSelectionForContact(contact); + + expect(selection.mode, PathSelectionMode.flood); + expect(selection.pathBytes, isEmpty); + }); + + test('clearing manual route falls back to the contact route', () async { + final contact = _buildContact( + seed: 5, + pathBytes: const [0x01, 0x02], + hopCount: 2, + hashSize: 1, + ); + final service = PathHistoryService(); + await service.initialize(); + await service.setManualSelectionFor( + contact.publicKeyHex, + PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList([0xAA, 0xBB]), + hopCount: 2, + hashSize: 1, + ), + ); + + await service.clearManualRouteFor(contact.publicKeyHex); + final selection = await service.getSelectionForContact(contact); + + expect(selection.mode, PathSelectionMode.directCurrent); + expect(selection.canonicalPath, '01,02'); + }); + + test('initialize removes legacy path history storage', () async { + final contact = Contact( + publicKey: Uint8List.fromList([ + 0xAB, + 0xC1, + 0x23, + ...List.filled(29, 0), + ]), + type: ContactType.chat, + flags: 0, + outPathLen: -1, + outPath: Uint8List(0), + advName: 'Legacy Contact', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + SharedPreferences.setMockInitialValues({ + 'contact_path_history_v2': '{"abc123":{"direct_paths":[]}}', + 'contact_manual_path_overrides_v1': jsonEncode({ + contact.publicKeyHex: { + 'pathBytes': [0xAA, 0xBB], + 'hopCount': 2, + 'hashSize': 1, + }, + }), + }); + final service = PathHistoryService(); + + await service.initialize(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.containsKey('contact_path_history_v2'), isFalse); + final selection = await service.getManualSelectionForContact(contact); + expect(selection, isNotNull); + expect(selection!.canonicalPath, 'AA,BB'); + }); +} diff --git a/test/services/profile_manager_test.dart b/test/services/profile_manager_test.dart new file mode 100644 index 0000000..cd648ec --- /dev/null +++ b/test/services/profile_manager_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/config_profile.dart'; +import 'package:meshcore_sar_app/services/profile_manager.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: ConfigProfile.defaultProfileId, + ); + }); + + group('ProfileManager', () { + test('shows the built-in default profile on a fresh install', () async { + final manager = ProfileManager(); + + await manager.initialize(); + + expect(manager.activeProfileId, ConfigProfile.defaultProfileId); + expect(manager.visibleProfiles, hasLength(1)); + expect( + manager.getProfile(ConfigProfile.defaultProfileId)?.isDefault, + isTrue, + ); + expect(ProfileStorageScope.profilesEnabled, isTrue); + expect(ProfileStorageScope.effectiveNamespace, isNull); + expect(manager.visibleProfiles.single.name, 'Default'); + expect(ProfileStorageScope.effectiveNamespace, isNull); + }); + + test( + 'persists custom profiles and restores scoped active profile state', + () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final profile = ConfigProfile( + id: 'profile-alpha', + name: 'Alpha', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + updatedAt: DateTime.parse('2026-03-16T12:00:00Z'), + sections: const ConfigProfileSections(), + ); + + await manager.upsertProfile(profile); + await manager.setActiveProfileId(profile.id); + + final reloaded = ProfileManager(); + await reloaded.initialize(); + + expect(reloaded.profilesEnabled, isTrue); + expect(reloaded.activeProfileId, profile.id); + expect(reloaded.visibleProfiles.map((item) => item.id), [ + ConfigProfile.defaultProfileId, + profile.id, + ]); + expect(reloaded.getProfile(profile.id)?.name, profile.name); + expect(ProfileStorageScope.effectiveNamespace, profile.id); + + await reloaded.setProfilesEnabled(false); + + expect(ProfileStorageScope.profilesEnabled, isFalse); + expect(ProfileStorageScope.effectiveNamespace, isNull); + expect(reloaded.activeProfileId, profile.id); + }, + ); + + test('stores per-device default profiles independently', () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final profile = ConfigProfile( + id: 'profile-alpha', + name: 'Alpha', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + updatedAt: DateTime.parse('2026-03-16T12:00:00Z'), + sections: const ConfigProfileSections(), + ); + + await manager.upsertProfile(profile); + await manager.setActiveProfileIdForDevice( + profile.id, + deviceKey: 'pk:device-a', + ); + await manager.setActiveProfileIdForDevice( + ConfigProfile.defaultProfileId, + deviceKey: 'pk:device-b', + ); + + final reloaded = ProfileManager(); + await reloaded.initialize(); + + expect(reloaded.profileIdForDevice('pk:device-a'), profile.id); + expect( + reloaded.profileIdForDevice('pk:device-b'), + ConfigProfile.defaultProfileId, + ); + expect( + reloaded.profileIdForDevice('pk:device-c'), + ConfigProfile.defaultProfileId, + ); + expect(reloaded.hasProfileForDevice('pk:device-a'), isTrue); + expect(reloaded.hasProfileForDevice('pk:device-c'), isFalse); + }); + }); +} diff --git a/test/services/profile_workspace_coordinator_test.dart b/test/services/profile_workspace_coordinator_test.dart new file mode 100644 index 0000000..c6ea614 --- /dev/null +++ b/test/services/profile_workspace_coordinator_test.dart @@ -0,0 +1,451 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_client/meshcore_client.dart'; +import 'package:meshcore_sar_app/models/config_profile.dart'; +import 'package:meshcore_sar_app/models/contact_group.dart'; +import 'package:meshcore_sar_app/models/device_info.dart'; +import 'package:meshcore_sar_app/providers/app_provider.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/drawing_provider.dart'; +import 'package:meshcore_sar_app/providers/map_provider.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:meshcore_sar_app/providers/voice_provider.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart' as ip; +import 'package:meshcore_sar_app/services/app_config_snapshot_service.dart'; +import 'package:meshcore_sar_app/services/contact_storage_service.dart'; +import 'package:meshcore_sar_app/services/device_config_applicator.dart'; +import 'package:meshcore_sar_app/services/message_storage_service.dart'; +import 'package:meshcore_sar_app/services/profile_manager.dart'; +import 'package:meshcore_sar_app/services/profile_workspace_coordinator.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; +import 'package:meshcore_sar_app/services/map_workspace_snapshot_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: ConfigProfile.defaultProfileId, + ); + }); + + group('ProfileWorkspaceCoordinator', () { + test( + 'createProfileFromCurrent starts with empty contacts and messages', + () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final messageStorage = MessageStorageService(); + final contactStorage = ContactStorageService(); + + await messageStorage.saveMessages([_buildMessage('default-msg')]); + await contactStorage.saveContacts([ + _buildContact('Default Contact', 1), + ]); + await contactStorage.saveContactGroups([ + SavedContactGroup( + id: 'group-1', + sectionKey: 'manual', + label: 'Team', + query: '', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + ), + ]); + await contactStorage.savePendingAdverts([ + {'id': 'pending-1'}, + ]); + + final coordinator = _buildCoordinator(profileManager: manager); + + final profile = await coordinator.createProfileFromCurrent( + name: 'Empty Profile', + ); + + expect( + await messageStorage.loadMessages(namespace: profile.id), + isEmpty, + ); + expect( + await contactStorage.loadContacts(namespace: profile.id), + isEmpty, + ); + expect( + await contactStorage.loadContactGroups(namespace: profile.id), + isEmpty, + ); + expect( + await contactStorage.loadPendingAdverts(namespace: profile.id), + isEmpty, + ); + + expect(await messageStorage.loadMessages(), hasLength(1)); + expect(await contactStorage.loadContacts(), hasLength(1)); + }, + ); + + test( + 'duplicateProfile copies contacts and messages from source profile', + () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final source = ConfigProfile( + id: 'profile-source', + name: 'Source', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + updatedAt: DateTime.parse('2026-03-16T12:00:00Z'), + sections: const ConfigProfileSections(), + ); + await manager.upsertProfile(source); + + final messageStorage = MessageStorageService(); + final contactStorage = ContactStorageService(); + + await messageStorage.saveMessages([ + _buildMessage('source-msg'), + ], namespace: source.id); + await contactStorage.saveContacts([ + _buildContact('Source Contact', 2), + ], namespace: source.id); + await contactStorage.saveContactGroups([ + SavedContactGroup( + id: 'source-group', + sectionKey: 'manual', + label: 'Source Team', + query: '', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + ), + ], namespace: source.id); + await contactStorage.savePendingAdverts([ + {'id': 'source-pending'}, + ], namespace: source.id); + + final coordinator = _buildCoordinator(profileManager: manager); + + final duplicate = await coordinator.duplicateProfile(source); + + final duplicatedMessages = await messageStorage.loadMessages( + namespace: duplicate.id, + ); + final duplicatedContacts = await contactStorage.loadContacts( + namespace: duplicate.id, + ); + final duplicatedGroups = await contactStorage.loadContactGroups( + namespace: duplicate.id, + ); + final duplicatedPending = await contactStorage.loadPendingAdverts( + namespace: duplicate.id, + ); + + expect(duplicatedMessages.map((item) => item.id), ['source-msg']); + expect(duplicatedContacts.map((item) => item.advName), [ + 'Source Contact', + ]); + expect(duplicatedGroups.map((item) => item.id), ['source-group']); + expect(duplicatedPending, [ + {'id': 'source-pending'}, + ]); + }, + ); + + test( + 'openProfile disconnects current connection before switching', + () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final target = ConfigProfile( + id: 'profile-target', + name: 'Target', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + updatedAt: DateTime.parse('2026-03-16T12:00:00Z'), + sections: const ConfigProfileSections(), + ); + await manager.upsertProfile(target); + + final connectionProvider = _FakeConnectionProvider(); + final coordinator = _buildCoordinator( + profileManager: manager, + connectionProvider: connectionProvider, + ); + + await coordinator.openProfile(target.id); + + expect(connectionProvider.disconnectCallCount, 1); + expect(manager.activeProfileId, target.id); + }, + ); + + test('syncActiveProfileForCurrentDevice uses per-device default', () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final alpha = ConfigProfile( + id: 'profile-alpha', + name: 'Alpha', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + updatedAt: DateTime.parse('2026-03-16T12:00:00Z'), + sections: const ConfigProfileSections(), + ); + await manager.upsertProfile(alpha); + await manager.setActiveProfileIdForDevice( + alpha.id, + deviceKey: 'pk:01020304', + ); + await manager.setActiveProfileId(ConfigProfile.defaultProfileId); + + final connectionProvider = _FakeConnectionProvider( + deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])), + ); + final voiceProvider = _FakeVoiceProvider(); + final imageProvider = _FakeImageProvider(); + final coordinator = _buildCoordinator( + profileManager: manager, + connectionProvider: connectionProvider, + voiceProvider: voiceProvider, + imageProvider: imageProvider, + ); + + await coordinator.syncActiveProfileForCurrentDevice(); + + expect(manager.activeProfileId, alpha.id); + expect(connectionProvider.disconnectCallCount, 0); + expect(voiceProvider.reloadCallCount, 1); + expect(imageProvider.reloadCallCount, 1); + }); + + test( + 'syncActiveProfileForCurrentDevice creates a profile for a new device', + () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final connectionProvider = _FakeConnectionProvider( + deviceInfo: DeviceInfo( + deviceId: 'ble-77', + deviceName: 'MeshCore-Field Unit', + selfName: 'Field Unit', + publicKey: Uint8List.fromList([7, 7, 7, 7]), + ), + ); + final coordinator = _buildCoordinator( + profileManager: manager, + connectionProvider: connectionProvider, + ); + + await coordinator.syncActiveProfileForCurrentDevice(); + + expect(manager.activeProfileId, isNot(ConfigProfile.defaultProfileId)); + final profile = manager.getProfile(manager.activeProfileId); + expect(profile, isNotNull); + expect(profile!.name, 'Device Field Unit'); + expect(manager.hasProfileForDevice('pk:07070707'), isTrue); + expect( + manager.profileIdForDevice('pk:07070707'), + manager.activeProfileId, + ); + }, + ); + }); +} + +ProfileWorkspaceCoordinator _buildCoordinator({ + required ProfileManager profileManager, + _FakeConnectionProvider? connectionProvider, + _FakeVoiceProvider? voiceProvider, + _FakeImageProvider? imageProvider, +}) { + return ProfileWorkspaceCoordinator( + profileManager: profileManager, + connectionProvider: connectionProvider ?? _FakeConnectionProvider(), + contactsProvider: _FakeContactsProvider(), + messagesProvider: _FakeMessagesProvider(), + sensorsProvider: _FakeSensorsProvider(), + mapProvider: _FakeMapProvider(), + drawingProvider: _FakeDrawingProvider(), + channelsProvider: _FakeChannelsProvider(), + voiceProvider: voiceProvider ?? _FakeVoiceProvider(), + imageProvider: imageProvider ?? _FakeImageProvider(), + appProvider: _FakeAppProvider(), + appConfigSnapshotService: _FakeAppConfigSnapshotService(), + mapWorkspaceSnapshotService: _FakeMapWorkspaceSnapshotService(), + deviceConfigApplicator: _FakeDeviceConfigApplicator(), + ); +} + +Message _buildMessage(String id) { + return Message( + id: id, + messageType: MessageType.channel, + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: id, + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + isRead: true, + ); +} + +Contact _buildContact(String name, int fillByte) { + return Contact( + publicKey: Uint8List.fromList(List.filled(32, fillByte)), + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (46.0569 * 1e6).round(), + advLon: (14.5058 * 1e6).round(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); +} + +class _FakeAppConfigSnapshotService extends AppConfigSnapshotService { + @override + Future capture(AppProvider appProvider) async { + return const AppSettingsProfileSection(); + } +} + +class _FakeMapWorkspaceSnapshotService extends MapWorkspaceSnapshotService { + @override + Future capture({ + required MapProvider mapProvider, + required DrawingProvider drawingProvider, + }) async { + return const MapWorkspaceProfileSection(); + } +} + +class _FakeDeviceConfigApplicator extends DeviceConfigApplicator { + @override + ConfigProfileSections capture({ + required ConnectionProvider connectionProvider, + required ChannelsProvider channelsProvider, + }) { + return const ConfigProfileSections(); + } +} + +class _FakeConnectionProvider implements ConnectionProvider { + _FakeConnectionProvider({DeviceInfo? deviceInfo}) + : deviceInfo = deviceInfo ?? DeviceInfo(); + + int disconnectCallCount = 0; + + @override + final DeviceInfo deviceInfo; + + @override + ConnectionMode get connectionMode => ConnectionMode.ble; + + @override + Future disconnect() async { + disconnectCallCount += 1; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeContactsProvider implements ContactsProvider { + @override + Future persistNow() async {} + + @override + Future reloadFromStorage({ + String? namespace, + Uint8List? devicePublicKey, + }) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeMessagesProvider implements MessagesProvider { + @override + Future persistNow() async {} + + @override + Future reloadFromStorage({String? namespace}) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeMapProvider implements MapProvider { + @override + Future reloadProfileScopedState() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeDrawingProvider implements DrawingProvider { + @override + Future reloadProfileScopedState() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSensorsProvider implements SensorsProvider { + @override + Future reloadProfileScopedState() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeChannelsProvider implements ChannelsProvider { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeVoiceProvider implements VoiceProvider { + int reloadCallCount = 0; + + @override + Future reloadProfileScopedState() async { + reloadCallCount += 1; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeImageProvider implements ip.ImageProvider { + int reloadCallCount = 0; + + @override + Future reloadProfileScopedState() async { + reloadCallCount += 1; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAppProvider implements AppProvider { + @override + Future reloadProfileScopedSettings() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/services/relay_candidate_sorter_test.dart b/test/services/relay_candidate_sorter_test.dart new file mode 100644 index 0000000..4192ae5 --- /dev/null +++ b/test/services/relay_candidate_sorter_test.dart @@ -0,0 +1,60 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/services/relay_candidate_sorter.dart'; + +void main() { + test('sorts relay candidates by distance from self point', () { + final sorter = RelayCandidateSorter(); + + final sorted = sorter.sortByDistanceFromSelf([ + _contact(seed: 1, name: 'Far', lat: 46.08, lon: 14.60), + _contact(seed: 2, name: 'Near', lat: 46.0570, lon: 14.5060), + _contact(seed: 3, name: 'Middle', lat: 46.06, lon: 14.52), + ], selfPoint: const LatLng(46.0569, 14.5058)); + + expect(sorted.map((contact) => contact.displayName).toList(), [ + 'Near', + 'Middle', + 'Far', + ]); + }); + + test('falls back to stable name ordering when self point is unavailable', () { + final sorter = RelayCandidateSorter(); + + final sorted = sorter.sortByDistanceFromSelf([ + _contact(seed: 1, name: 'Zulu', lat: 46.08, lon: 14.60), + _contact(seed: 2, name: 'Alpha', lat: 46.0570, lon: 14.5060), + ], selfPoint: null); + + expect(sorted.map((contact) => contact.displayName).toList(), [ + 'Alpha', + 'Zulu', + ]); + }); +} + +Contact _contact({ + required int seed, + required String name, + required double lat, + required double lon, +}) { + final publicKey = Uint8List(32)..fillRange(0, 32, seed); + return Contact( + publicKey: publicKey, + type: ContactType.repeater, + flags: 0, + outPathLen: -1, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (lat * 1e6).round(), + advLon: (lon * 1e6).round(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); +} diff --git a/test/services/traffic_stats_reporting_service_test.dart b/test/services/traffic_stats_reporting_service_test.dart new file mode 100644 index 0000000..d5ced40 --- /dev/null +++ b/test/services/traffic_stats_reporting_service_test.dart @@ -0,0 +1,280 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:meshcore_sar_app/models/ble_packet_log.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; +import 'package:meshcore_sar_app/services/traffic_stats_reporting_service.dart'; + +BlePacketLog _log({ + required DateTime timestamp, + required List rawData, + int responseCode = 0x88, +}) { + return BlePacketLog( + timestamp: timestamp, + rawData: Uint8List.fromList(rawData), + direction: PacketDirection.rx, + responseCode: responseCode, + ); +} + +List _routeRaw({ + required int payloadType, + required int pathDescriptor, + List pathBytes = const [], +}) { + return [ + 0x88, + 0x00, + 0x00, + payloadType << 2, + 0x00, + 0x00, + 0x00, + 0x00, + pathDescriptor, + ...pathBytes, + ]; +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); + }); + + test('uploads fixed packet type and path mode counters', () async { + final capturedPayloads = >[]; + DateTime now = DateTime.utc(2026, 4, 3, 10, 6); + final service = TrafficStatsReportingService( + client: MockClient((request) async { + capturedPayloads.add( + jsonDecode(request.body) as Map, + ); + return http.Response('{}', 200); + }), + now: () => now, + appVersionProvider: () async => '2026.0402.1+44', + ); + + await service.initialize( + deviceKey6Provider: () => 'a1b2c3d4e5f6', + ); + expect(service.isEnabled, isTrue); + await service.processLogs([ + _log( + timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5), + rawData: _routeRaw( + payloadType: 0x04, + pathDescriptor: 0x01, + pathBytes: const [0xC0], + ), + ), + _log( + timestamp: DateTime.utc(2026, 4, 3, 10, 0, 10), + rawData: _routeRaw( + payloadType: 0x05, + pathDescriptor: 0x41, + pathBytes: const [0xC0, 0x10], + ), + ), + _log( + timestamp: DateTime.utc(2026, 4, 3, 10, 0, 15), + rawData: _routeRaw( + payloadType: 0x08, + pathDescriptor: 0x81, + pathBytes: const [0xC0, 0x10, 0x63], + ), + ), + _log( + timestamp: DateTime.utc(2026, 4, 3, 10, 0, 20), + rawData: _routeRaw( + payloadType: 0x01, + pathDescriptor: 0x00, + ), + ), + ]); + + expect(capturedPayloads, hasLength(1)); + + final payload = capturedPayloads.single; + final counts = payload['counts'] as Map; + expect(payload['deviceKey6'], 'a1b2c3d4e5f6'); + expect(payload.containsKey('publicKey'), isFalse); + expect(payload.containsKey('location'), isFalse); + expect(counts['pt_04'], 1); + expect(counts['pt_05'], 1); + expect(counts['pt_08'], 1); + expect(counts['pt_01'], 1); + expect(counts['path_mode_1b'], 1); + expect(counts['path_mode_2b'], 1); + expect(counts['path_mode_3b'], 1); + expect(counts['path_mode_none'], 1); + expect(service.pendingUploadCount, 0); + + service.dispose(); + }); + + test('classifies malformed route descriptors as decode and path failures', () async { + final capturedPayloads = >[]; + final service = TrafficStatsReportingService( + client: MockClient((request) async { + capturedPayloads.add( + jsonDecode(request.body) as Map, + ); + return http.Response('{}', 200); + }), + now: () => DateTime.utc(2026, 4, 3, 10, 6), + appVersionProvider: () async => '2026.0402.1+44', + ); + + await service.initialize( + deviceKey6Provider: () => 'a1b2c3d4e5f6', + ); + await service.setEnabled(true); + await service.processLogs([ + _log( + timestamp: DateTime.utc(2026, 4, 3, 10, 0, 30), + rawData: _routeRaw( + payloadType: 0x04, + pathDescriptor: 0x41, + ), + ), + ]); + + final counts = + (capturedPayloads.single['counts'] as Map); + expect(counts['decode_fail'], 1); + expect(counts['path_mode_unknown'], 1); + + service.dispose(); + }); + + test('persists queue and retries deterministically', () async { + final requestBodies = >[]; + var shouldFail = true; + DateTime now = DateTime.utc(2026, 4, 3, 10, 6); + + final failingService = TrafficStatsReportingService( + client: MockClient((request) async { + requestBodies.add( + jsonDecode(request.body) as Map, + ); + if (shouldFail) { + return http.Response('nope', 503); + } + return http.Response('{}', 200); + }), + now: () => now, + appVersionProvider: () async => '2026.0402.1+44', + ); + + await failingService.initialize( + deviceKey6Provider: () => 'a1b2c3d4e5f6', + ); + await failingService.setEnabled(true); + await failingService.processLogs([ + _log( + timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5), + rawData: _routeRaw( + payloadType: 0x04, + pathDescriptor: 0x01, + pathBytes: const [0xC0], + ), + ), + ]); + + expect(failingService.pendingUploadCount, 1); + expect(failingService.lastError, 'Upload failed (503)'); + final prefs = await SharedPreferences.getInstance(); + expect( + prefs.containsKey('traffic_stats_reporting_queue'), + isTrue, + ); + expect( + requestBodies.single['reportId'], + 'a1b2c3d4e5f6:2026-04-03T10:00:00.000Z', + ); + failingService.dispose(); + + shouldFail = false; + now = DateTime.utc(2026, 4, 3, 10, 7); + final retryService = TrafficStatsReportingService( + client: MockClient((request) async { + requestBodies.add( + jsonDecode(request.body) as Map, + ); + return http.Response('{}', 200); + }), + now: () => now, + appVersionProvider: () async => '2026.0402.1+44', + ); + await retryService.initialize( + deviceKey6Provider: () => 'a1b2c3d4e5f6', + ); + await retryService.flushPendingUploads(); + + expect(retryService.pendingUploadCount, 0); + expect(retryService.lastError, isNull); + expect(retryService.lastSuccessAt, now); + retryService.dispose(); + }); + + test('ignores legacy interval preferences and keeps 5 minute windows', () async { + SharedPreferences.setMockInitialValues({ + 'traffic_stats_reporting_interval_minutes': 15, + }); + final capturedPayloads = >[]; + final service = TrafficStatsReportingService( + client: MockClient((request) async { + capturedPayloads.add( + jsonDecode(request.body) as Map, + ); + return http.Response('{}', 200); + }), + now: () => DateTime.utc(2026, 4, 3, 10, 6), + appVersionProvider: () async => '2026.0402.1+44', + ); + + await service.initialize( + deviceKey6Provider: () => 'a1b2c3d4e5f6', + ); + await service.setEnabled(true); + await service.processLogs([ + _log( + timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5), + rawData: _routeRaw( + payloadType: 0x04, + pathDescriptor: 0x01, + pathBytes: const [0xC0], + ), + ), + ]); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('traffic_stats_reporting_enabled'), isTrue); + expect(prefs.containsKey('traffic_stats_reporting_interval_minutes'), isFalse); + expect( + prefs.containsKey('profile.alpha.traffic_stats_reporting_enabled'), + isFalse, + ); + expect( + prefs.containsKey( + 'profile.alpha.traffic_stats_reporting_interval_minutes', + ), + isFalse, + ); + expect(service.intervalMinutes, 5); + expect(capturedPayloads, hasLength(1)); + + service.dispose(); + }); +} diff --git a/test/utils/avatar_label_helper_test.dart b/test/utils/avatar_label_helper_test.dart new file mode 100644 index 0000000..b298b92 --- /dev/null +++ b/test/utils/avatar_label_helper_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/avatar_label_helper.dart'; + +void main() { + group('AvatarLabelHelper.buildLabel', () { + test('returns two initials for multi-word names', () { + expect(AvatarLabelHelper.buildLabel('John Smith'), 'JS'); + }); + + test('returns first two characters for single-word names', () { + expect(AvatarLabelHelper.buildLabel('Alpha'), 'AL'); + }); + + test('allows three compact characters for hash-prefixed names', () { + expect(AvatarLabelHelper.buildLabel('#ops'), '#OP'); + }); + + test('removes spacing after hash-prefixed names', () { + expect(AvatarLabelHelper.buildLabel('# foo alpha'), '#FO'); + }); + + test('keeps non-hash labels at two characters', () { + expect(AvatarLabelHelper.buildLabel('abc'), 'AB'); + }); + + test('strips emoji when building fallback initials', () { + expect(AvatarLabelHelper.buildLabel('Charlie 🙂 Delta'), 'CD'); + }); + + test('strips leading emoji when building fallback initials', () { + expect(AvatarLabelHelper.buildLabel('🙂 Charlie'), 'CH'); + }); + }); +} diff --git a/test/utils/contact_grouping_test.dart b/test/utils/contact_grouping_test.dart new file mode 100644 index 0000000..628a19f --- /dev/null +++ b/test/utils/contact_grouping_test.dart @@ -0,0 +1,364 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/utils/contact_grouping.dart'; + +void main() { + Contact buildContact({ + required int seed, + required String name, + required DateTime lastSeen, + }) { + return Contact( + publicKey: Uint8List.fromList( + List.generate(32, (index) => (seed + index) % 255), + ), + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: lastSeen.millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: lastSeen.millisecondsSinceEpoch ~/ 1000, + ); + } + + group('ContactGrouping.inferGroups', () { + test( + 'groups prefixed contacts only when at least four share the prefix', + () { + final now = DateTime(2026, 3, 10, 12); + final groups = ContactGrouping.inferGroups([ + buildContact(seed: 1, name: 'SI-1', lastSeen: now), + buildContact( + seed: 2, + name: 'SI-2', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact( + seed: 3, + name: 'SI-3', + lastSeen: now.subtract(const Duration(minutes: 2)), + ), + buildContact( + seed: 4, + name: 'SI-4', + lastSeen: now.subtract(const Duration(minutes: 3)), + ), + buildContact( + seed: 5, + name: 'OTHER', + lastSeen: now.subtract(const Duration(minutes: 4)), + ), + ]); + + expect(groups, hasLength(1)); + expect(groups.first.label, 'SI-'); + expect(groups.first.contacts.map((contact) => contact.displayName), [ + 'SI-1', + 'SI-2', + 'SI-3', + 'SI-4', + ]); + }, + ); + + test('does not group when only three contacts share a prefix', () { + final now = DateTime(2026, 3, 10, 12); + final groups = ContactGrouping.inferGroups([ + buildContact(seed: 1, name: 'SI-1', lastSeen: now), + buildContact( + seed: 2, + name: 'SI-2', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact( + seed: 3, + name: 'SI-3', + lastSeen: now.subtract(const Duration(minutes: 2)), + ), + ]); + + expect(groups, isEmpty); + }); + + test('orders groups by latest last seen', () { + final now = DateTime(2026, 3, 10, 12); + final groups = ContactGrouping.inferGroups([ + buildContact(seed: 2, name: 'SI-1', lastSeen: now), + buildContact( + seed: 3, + name: 'SI-2', + lastSeen: now.subtract(const Duration(minutes: 2)), + ), + buildContact( + seed: 4, + name: 'SI-3', + lastSeen: now.subtract(const Duration(minutes: 3)), + ), + buildContact( + seed: 5, + name: 'SI-4', + lastSeen: now.subtract(const Duration(minutes: 4)), + ), + buildContact( + seed: 6, + name: 'HR-1', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact( + seed: 7, + name: 'HR-2', + lastSeen: now.subtract(const Duration(minutes: 5)), + ), + buildContact( + seed: 8, + name: 'HR-3', + lastSeen: now.subtract(const Duration(minutes: 6)), + ), + buildContact( + seed: 9, + name: 'HR-4', + lastSeen: now.subtract(const Duration(minutes: 7)), + ), + ]); + + expect(groups, hasLength(2)); + expect(groups.first.label, 'SI-'); + expect(groups.last.label, 'HR-'); + }); + + test('collapses extra auto-groups into a shared parent prefix', () { + final now = DateTime(2026, 3, 10, 12); + final groups = ContactGrouping.inferGroups( + [ + buildContact(seed: 1, name: 'AL-1', lastSeen: now), + buildContact( + seed: 2, + name: 'AL-2', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact( + seed: 3, + name: 'AL-3', + lastSeen: now.subtract(const Duration(minutes: 2)), + ), + buildContact( + seed: 4, + name: 'AL-4', + lastSeen: now.subtract(const Duration(minutes: 3)), + ), + buildContact( + seed: 5, + name: 'BR-1', + lastSeen: now.subtract(const Duration(minutes: 4)), + ), + buildContact( + seed: 6, + name: 'BR-2', + lastSeen: now.subtract(const Duration(minutes: 5)), + ), + buildContact( + seed: 7, + name: 'BR-3', + lastSeen: now.subtract(const Duration(minutes: 6)), + ), + buildContact( + seed: 8, + name: 'BR-4', + lastSeen: now.subtract(const Duration(minutes: 7)), + ), + buildContact( + seed: 9, + name: 'HU-PE-1', + lastSeen: now.subtract(const Duration(minutes: 8)), + ), + buildContact( + seed: 10, + name: 'HU-PE-2', + lastSeen: now.subtract(const Duration(minutes: 9)), + ), + buildContact( + seed: 11, + name: 'HU-GA-1', + lastSeen: now.subtract(const Duration(minutes: 10)), + ), + buildContact( + seed: 12, + name: 'HU-GA-2', + lastSeen: now.subtract(const Duration(minutes: 11)), + ), + buildContact( + seed: 13, + name: 'HU-PE-3', + lastSeen: now.subtract(const Duration(minutes: 12)), + ), + buildContact( + seed: 14, + name: 'HU-PE-4', + lastSeen: now.subtract(const Duration(minutes: 13)), + ), + buildContact( + seed: 15, + name: 'HU-GA-3', + lastSeen: now.subtract(const Duration(minutes: 14)), + ), + buildContact( + seed: 16, + name: 'HU-GA-4', + lastSeen: now.subtract(const Duration(minutes: 15)), + ), + ], + maxNamedGroups: 2, + overflowGroupLabel: 'Others', + ); + + expect(groups, hasLength(3)); + expect(groups[0].label, 'AL-'); + expect(groups[1].label, 'BR-'); + expect(groups[2].label, 'HU-'); + expect(groups[2].contacts.map((contact) => contact.displayName), [ + 'HU-PE-1', + 'HU-PE-2', + 'HU-GA-1', + 'HU-GA-2', + 'HU-PE-3', + 'HU-PE-4', + 'HU-GA-3', + 'HU-GA-4', + ]); + expect(groups[2].matchPrefixes, ['HU-']); + }); + + test( + 'does not create an Others bucket when overflow has no shared parent', + () { + final now = DateTime(2026, 3, 10, 12); + final groups = ContactGrouping.inferGroups( + [ + buildContact(seed: 1, name: 'AL-1', lastSeen: now), + buildContact( + seed: 2, + name: 'AL-2', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact( + seed: 3, + name: 'AL-3', + lastSeen: now.subtract(const Duration(minutes: 2)), + ), + buildContact( + seed: 4, + name: 'AL-4', + lastSeen: now.subtract(const Duration(minutes: 3)), + ), + buildContact( + seed: 5, + name: 'BR-1', + lastSeen: now.subtract(const Duration(minutes: 4)), + ), + buildContact( + seed: 6, + name: 'BR-2', + lastSeen: now.subtract(const Duration(minutes: 5)), + ), + buildContact( + seed: 7, + name: 'BR-3', + lastSeen: now.subtract(const Duration(minutes: 6)), + ), + buildContact( + seed: 8, + name: 'BR-4', + lastSeen: now.subtract(const Duration(minutes: 7)), + ), + buildContact( + seed: 9, + name: 'CR-1', + lastSeen: now.subtract(const Duration(minutes: 8)), + ), + buildContact( + seed: 10, + name: 'CR-2', + lastSeen: now.subtract(const Duration(minutes: 9)), + ), + buildContact( + seed: 11, + name: 'CR-3', + lastSeen: now.subtract(const Duration(minutes: 10)), + ), + buildContact( + seed: 12, + name: 'CR-4', + lastSeen: now.subtract(const Duration(minutes: 11)), + ), + ], + maxNamedGroups: 2, + overflowGroupLabel: 'Others', + ); + + expect(groups.map((group) => group.label), ['AL-', 'BR-', 'CR-']); + }, + ); + }); + + group('ContactGrouping.contactMatchesInferredGroupLabel', () { + test('matches the inferred auto-group label for prefixed contacts', () { + final contact = buildContact( + seed: 99, + name: 'SI-1', + lastSeen: DateTime(2026, 3, 10, 12), + ); + + expect( + ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI-'), + isTrue, + ); + expect( + ContactGrouping.contactMatchesInferredGroupLabel(contact, 'si'), + isTrue, + ); + }); + + test('returns false when a contact has no inferred auto-group label', () { + final contact = buildContact( + seed: 100, + name: 'Lone Contact', + lastSeen: DateTime(2026, 3, 10, 12), + ); + + expect( + ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI'), + isFalse, + ); + }); + + test('finds a shared inferred label for related repeater names', () { + final contacts = [ + buildContact( + seed: 101, + name: 'HU-PE', + lastSeen: DateTime(2026, 3, 10, 12), + ), + buildContact( + seed: 102, + name: 'HU-GA', + lastSeen: DateTime(2026, 3, 10, 11, 59), + ), + ]; + + expect(ContactGrouping.sharedInferredGroupLabel(contacts), 'HU-'); + }); + + test('finds a shared parent label for related subgroup prefixes', () { + expect( + ContactGrouping.sharedParentGroupLabel(['HU-PE-', 'HU-GA-']), + 'HU-', + ); + expect(ContactGrouping.sharedParentGroupLabel(['AL-', 'BR-']), isNull); + }); + }); +} diff --git a/test/utils/drawing_message_parser_test.dart b/test/utils/drawing_message_parser_test.dart new file mode 100644 index 0000000..30125b6 --- /dev/null +++ b/test/utils/drawing_message_parser_test.dart @@ -0,0 +1,354 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_sar_app/models/map_drawing.dart'; +import 'package:meshcore_sar_app/models/map_coordinate_space.dart'; +import 'package:meshcore_sar_app/utils/drawing_message_parser.dart'; + +void main() { + group('DrawingMessageParser - Critical String Formatting Tests', () { + test('createDrawingMessage returns raw string, not Object', () { + final drawing = LineDrawing( + id: 'test-123', + color: DrawingColors.palette[0], + createdAt: DateTime.now(), + points: [LatLng(37.7749, -122.4194), LatLng(37.7750, -122.4195)], + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + + // CRITICAL: Must be a String type + expect(message, isA()); + + // CRITICAL: Must NOT contain "Instance of" or "Object" + expect(message, isNot(contains('Instance of'))); + expect(message, isNot(contains('Object'))); + + // CRITICAL: Must start with D: prefix + expect(message, startsWith('D:')); + + // CRITICAL: Must be valid JSON after prefix + final jsonPart = message.substring(2); + expect(() => jsonPart, returnsNormally); + }); + + test('createDrawingMessage produces parseable JSON string', () { + final drawing = LineDrawing( + id: 'test-456', + color: DrawingColors.palette[2], // green + createdAt: DateTime.now(), + points: [ + LatLng(40.7128, -74.0060), + LatLng(40.7129, -74.0061), + LatLng(40.7130, -74.0062), + ], + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + + // Should be parseable back + final parsed = DrawingMessageParser.parseDrawingMessage( + message, + senderName: 'Test Sender', + messageId: 'msg-123', + ); + + expect(parsed, isNotNull); + expect(parsed, isA()); + expect((parsed as LineDrawing).points.length, equals(3)); + }); + + test( + 'createDrawingMessage handles rectangle with proper string format', + () { + final drawing = RectangleDrawing( + id: 'rect-789', + color: DrawingColors.palette[4], // orange + createdAt: DateTime.now(), + topLeft: LatLng(45.5231, -122.6765), + bottomRight: LatLng(45.5100, -122.6600), + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + + // CRITICAL: Must be pure string + expect(message, isA()); + expect(message, startsWith('D:')); + + // Should contain compact JSON format + expect(message, contains('"t":')); + expect(message, contains('"c":')); + expect(message, contains('"b":')); + + // Must NOT contain any object representations + expect(message, isNot(contains('RectangleDrawing'))); + expect(message, isNot(contains('Instance'))); + }, + ); + + test('custom-map drawings use D2 format and preserve map metadata', () { + final drawing = LineDrawing( + id: 'custom-1', + color: DrawingColors.palette[1], + createdAt: DateTime.now(), + points: [LatLng(120, 200), LatLng(320, 450)], + coordinateSpace: MapCoordinateSpace.customMap, + mapId: '1234567890abcdef', + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + + expect(message, startsWith('D2:')); + + final parsed = DrawingMessageParser.parseDrawingMessage(message); + expect(parsed, isA()); + expect(parsed!.coordinateSpace, MapCoordinateSpace.customMap); + expect(parsed.mapId, '1234567890ab'); + final parsedLine = parsed as LineDrawing; + expect(parsedLine.points.first.latitude, 120); + expect(parsedLine.points.first.longitude, 200); + }); + + test('JSON encoding produces string with coordinates as numbers', () { + final drawing = LineDrawing( + id: 'coord-test', + color: DrawingColors.palette[1], // blue + createdAt: DateTime.now(), + points: [LatLng(37.77490, -122.41940)], + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + + // Extract JSON part + final jsonStr = message.substring(2); + + // CRITICAL: Coordinates must be numbers, not strings + // Format: {"t":0,"c":1,"p":[37.7749,-122.4194]} + expect(jsonStr, contains('37.7749')); + expect(jsonStr, contains('-122.4194')); + + // Should NOT have coordinates as quoted strings + expect(jsonStr, isNot(contains('"37.7749"'))); + expect(jsonStr, isNot(contains('"-122.4194"'))); + }); + + test('isDrawingMessage correctly identifies valid drawing messages', () { + expect( + DrawingMessageParser.isDrawingMessage('D:{"t":0,"c":1,"p":[1,2]}'), + isTrue, + ); + expect(DrawingMessageParser.isDrawingMessage('S:🧑:37,-122'), isFalse); + expect(DrawingMessageParser.isDrawingMessage('Plain text'), isFalse); + expect(DrawingMessageParser.isDrawingMessage('D:'), isTrue); + }); + + test('parseDrawingMessage returns null for malformed messages', () { + expect(DrawingMessageParser.parseDrawingMessage('Not a drawing'), isNull); + expect( + DrawingMessageParser.parseDrawingMessage('D:invalid json'), + isNull, + ); + }); + + test('round-trip: create -> parse -> create produces consistent output', () { + final original = LineDrawing( + id: 'roundtrip-1', + color: DrawingColors.palette[3], // yellow + createdAt: DateTime.now(), + points: [LatLng(51.5074, -0.1278), LatLng(51.5075, -0.1279)], + ); + + // Create message + final message1 = DrawingMessageParser.createDrawingMessage(original); + + // Parse it + final parsed = DrawingMessageParser.parseDrawingMessage( + message1, + senderName: 'TestUser', + messageId: 'msg-rt1', + ); + + expect(parsed, isNotNull); + + // Create message again from parsed + final message2 = DrawingMessageParser.createDrawingMessage(parsed!); + + // Both messages should have same structure (excluding metadata like IDs) + expect(message1.substring(0, 10), equals(message2.substring(0, 10))); + }); + + test('color indices are preserved as integers in JSON', () { + for (int i = 0; i < DrawingColors.palette.length; i++) { + final drawing = LineDrawing( + id: 'color-$i', + color: DrawingColors.palette[i], + createdAt: DateTime.now(), + points: [LatLng(0, 0)], + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + final jsonStr = message.substring(2); + + // Color index should be integer in JSON + expect(jsonStr, contains('"c":$i')); + } + }); + + test('type indices are preserved correctly', () { + // Line = type 0 + final line = LineDrawing( + id: 'line-type', + color: DrawingColors.palette[0], + createdAt: DateTime.now(), + points: [LatLng(1, 1), LatLng(2, 2)], + ); + + final lineMsg = DrawingMessageParser.createDrawingMessage(line); + expect(lineMsg, contains('"t":0')); + + // Rectangle = type 1 + final rect = RectangleDrawing( + id: 'rect-type', + color: DrawingColors.palette[0], + createdAt: DateTime.now(), + topLeft: LatLng(1, 1), + bottomRight: LatLng(2, 2), + ); + + final rectMsg = DrawingMessageParser.createDrawingMessage(rect); + expect(rectMsg, contains('"t":1')); + }); + + test('coordinates are rounded to 5 decimal places', () { + final drawing = LineDrawing( + id: 'precision-test', + color: DrawingColors.palette[0], + createdAt: DateTime.now(), + points: [LatLng(37.774901234567, -122.419401234567)], + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + final jsonStr = message.substring(2); + + // Should be rounded to 5 decimals + expect(jsonStr, contains('37.7749')); + expect(jsonStr, contains('-122.4194')); + + // Should NOT contain extra precision + expect(jsonStr, isNot(contains('37.774901234567'))); + }); + + test('empty points array produces valid message', () { + final drawing = LineDrawing( + id: 'empty-line', + color: DrawingColors.palette[0], + createdAt: DateTime.now(), + points: [], + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + + expect(message, isA()); + expect(message, startsWith('D:')); + expect(message, contains('"p":[]')); + }); + + test('large coordinate values are handled correctly', () { + final drawing = LineDrawing( + id: 'large-coords', + color: DrawingColors.palette[0], + createdAt: DateTime.now(), + points: [ + LatLng(89.99999, 179.99999), // Near max valid coords + LatLng(-89.99999, -179.99999), // Near min valid coords + ], + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + + expect(message, isA()); + expect(message, contains('89.99999')); + expect(message, contains('179.99999')); + expect(message, contains('-89.99999')); + expect(message, contains('-179.99999')); + }); + + test('getDrawingTypeDisplay returns correct type names', () { + final lineMsg = 'D:{"t":0,"c":1,"p":[1,2,3,4]}'; + final rectMsg = 'D:{"t":1,"c":2,"b":[1,2,3,4]}'; + + expect( + DrawingMessageParser.getDrawingTypeDisplay(lineMsg), + equals('Line'), + ); + expect( + DrawingMessageParser.getDrawingTypeDisplay(rectMsg), + equals('Rectangle'), + ); + expect(DrawingMessageParser.getDrawingTypeDisplay('Invalid'), isNull); + }); + + test('getColorName returns correct color names', () { + for (int i = 0; i < 8; i++) { + final msg = 'D:{"t":0,"c":$i,"p":[0,0]}'; + final colorName = DrawingMessageParser.getColorName(msg); + expect(colorName, isNotNull); + expect(colorName, isA()); + } + + expect(DrawingMessageParser.getColorName('Invalid'), isNull); + }); + + test('getDrawingMetadata extracts correct information', () { + final lineMsg = 'D:{"t":0,"c":2,"p":[1,2,3,4,5,6]}'; + final metadata = DrawingMessageParser.getDrawingMetadata(lineMsg); + + expect(metadata, isNotNull); + expect(metadata!['type'], equals('Line')); + expect(metadata['color'], equals('Green')); + expect(metadata['pointCount'], equals(3)); // 6 values = 3 points + }); + + test('message does not contain sender metadata in JSON', () { + final drawing = LineDrawing( + id: 'no-sender', + color: DrawingColors.palette[0], + createdAt: DateTime.now(), + points: [LatLng(1, 2)], + senderName: 'TestSender', // Should NOT be in network JSON + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + + // CRITICAL: Sender name should NOT be in the message + expect(message, isNot(contains('sender'))); + expect(message, isNot(contains('TestSender'))); + + // Should only contain compact fields: t, c, p (or b) + final jsonStr = message.substring(2); + expect(jsonStr, contains('"t":')); + expect(jsonStr, contains('"c":')); + expect(jsonStr, matches(RegExp(r'"p":|"b":'))); + }); + + test('special characters in metadata do not break format', () { + // Even though sender name isn't included in network JSON, + // test that it doesn't affect the message creation + final drawing = LineDrawing( + id: 'special-chars-"quotes"', + color: DrawingColors.palette[0], + createdAt: DateTime.now(), + points: [LatLng(1, 2)], + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + + expect(message, isA()); + expect(message, startsWith('D:')); + // Should still be parseable + expect( + () => DrawingMessageParser.parseDrawingMessage(message), + returnsNormally, + ); + }); + }); +} diff --git a/test/utils/fast_gps_packet_test.dart b/test/utils/fast_gps_packet_test.dart new file mode 100644 index 0000000..189c557 --- /dev/null +++ b/test/utils/fast_gps_packet_test.dart @@ -0,0 +1,122 @@ +import 'dart:typed_data'; +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/fast_gps_packet.dart'; + +void main() { + group('FastGpsPacket', () { + test('encodes and parses a valid 16-byte packet', () { + final packet = FastGpsPacket( + senderKey6: 'aabbccddeeff', + latitude: 46.0569, + longitude: 14.5058, + speedKmh: 37, + ); + + final encoded = packet.encodeBinary(); + expect(encoded.length, equals(16)); + expect(encoded[0], equals(FastGpsPacket.magic)); + + final parsed = FastGpsPacket.tryParseBinary(encoded); + + expect(parsed, isNotNull); + expect(parsed!.senderKey6, equals('aabbccddeeff')); + expect(parsed.latitude, closeTo(46.0569, 0.000001)); + expect(parsed.longitude, closeTo(14.5058, 0.000001)); + expect(parsed.speedKmh, equals(37)); + }); + + test('clamps speed to a single unsigned byte', () { + final packet = FastGpsPacket( + senderKey6: '001122334455', + latitude: 1.0, + longitude: 2.0, + speedKmh: 999, + ); + + final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary()); + expect(parsed, isNotNull); + expect(parsed!.speedKmh, equals(255)); + }); + + test('supports negative coordinates', () { + final packet = FastGpsPacket( + senderKey6: '001122334455', + latitude: -33.8688, + longitude: -151.2093, + speedKmh: 0, + ); + + final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary()); + expect(parsed, isNotNull); + expect(parsed!.latitude, closeTo(-33.8688, 0.000001)); + expect(parsed.longitude, closeTo(-151.2093, 0.000001)); + }); + + test('rejects malformed payloads', () { + // Too short. + expect( + FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])), + isNull, + ); + // Wrong magic at the correct length. + expect( + FastGpsPacket.tryParseBinary( + Uint8List.fromList(List.filled(16, 0)..[0] = 0x48), + ), + isNull, + ); + // Legacy 19-byte format is no longer accepted. + expect( + FastGpsPacket.tryParseBinary( + Uint8List.fromList(List.filled(19, 0)..[0] = 0x47), + ), + isNull, + ); + }); + + test('rejects invalid coordinate ranges', () { + final payload = Uint8List(16); + payload[0] = FastGpsPacket.magic; + payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]); + final data = ByteData.sublistView(payload); + data.setInt32( + 7, + (91.0 * FastGpsPacket.coordinateScale).round(), + Endian.little, + ); + data.setInt32( + 11, + (14.5 * FastGpsPacket.coordinateScale).round(), + Endian.little, + ); + data.setUint8(15, 1); + + expect(FastGpsPacket.tryParseBinary(payload), isNull); + }); + + test('preserves at least meter accuracy', () { + const latitude = 46.0569123; + const longitude = 14.5058123; + final packet = FastGpsPacket( + senderKey6: 'aabbccddeeff', + latitude: latitude, + longitude: longitude, + speedKmh: 5, + ); + + final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary()); + expect(parsed, isNotNull); + + final latMeters = (parsed!.latitude - latitude).abs() * 111320.0; + final lonMeters = + (parsed.longitude - longitude).abs() * + 111320.0 * + math.cos(latitude * math.pi / 180.0); + + expect(latMeters, lessThan(1.0)); + expect(lonMeters, lessThan(1.0)); + }); + }); +} diff --git a/test/utils/image_message_parser_test.dart b/test/utils/image_message_parser_test.dart new file mode 100644 index 0000000..26001d6 --- /dev/null +++ b/test/utils/image_message_parser_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/image_message_parser.dart'; + +void main() { + group('ImageEnvelope', () { + test('encodes and parses IE4 with compressed session id', () { + final env = ImageEnvelope( + sessionId: '0000000a', + format: ImageFormat.avif, + total: 14, + width: 256, + height: 171, + sizeBytes: 2100, + ); + + final text = env.encode(); + expect(text.startsWith('IE4:'), isTrue); + expect(text.split(':')[1], equals('a')); + + final parsed = ImageEnvelope.tryParse(text); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('0000000a')); + expect(parsed.format, equals(ImageFormat.avif)); + expect(parsed.total, equals(14)); + expect(parsed.width, equals(256)); + expect(parsed.height, equals(171)); + expect(parsed.sizeBytes, equals(2100)); + expect(parsed.version, equals(4)); + }); + + test('rejects IE1 legacy prefix', () { + const legacy = 'IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1'; + expect(ImageEnvelope.tryParse(legacy), isNull); + }); + + }); + + group('ImageFetchRequest', () { + test('encodes and parses IR4 with compressed sid', () { + final req = ImageFetchRequest( + sessionId: '0000000a', + requesterKey6: 'ffeeddccbbaa', + ); + + final text = req.encode(); + expect(text.startsWith('IR4:'), isTrue); + expect(text.split(':')[1], equals('a')); + + final parsed = ImageFetchRequest.tryParse(text); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('0000000a')); + expect(parsed.want, equals('all')); + expect(parsed.requesterKey6, equals('ffeeddccbbaa')); + expect(parsed.version, equals(4)); + }); + + test('encodes and parses compact missing index ranges', () { + final req = ImageFetchRequest( + sessionId: '0000000a', + want: 'missing', + missingIndices: const [0, 1, 2, 5, 6, 8], + requesterKey6: 'ffeeddccbbaa', + ); + + final text = req.encode(); + expect(text, contains(':m0-2.5-6.8:')); + + final parsed = ImageFetchRequest.tryParse(text); + expect(parsed, isNotNull); + expect(parsed!.want, equals('missing')); + expect(parsed.missingIndices, equals([0, 1, 2, 5, 6, 8])); + }); + + test('rejects IR1 legacy prefix', () { + const legacy = 'IR1:00112233:a:ffeeddccbbaa:1700000001:1'; + expect(ImageFetchRequest.tryParse(legacy), isNull); + }); + + test('encodes and parses binary fetch request', () { + final req = ImageFetchRequest( + sessionId: '01020304', + want: 'missing', + missingIndices: const [0, 2, 5], + requesterKey6: 'ffeeddccbbaa', + ); + + final payload = req.encodeBinary(); + expect(ImageFetchRequest.isRequestBinary(payload), isTrue); + + final parsed = ImageFetchRequest.tryParseBinary(payload); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('01020304')); + expect(parsed.want, equals('missing')); + expect(parsed.missingIndices, equals([0, 2, 5])); + expect(parsed.requesterKey6, equals('ffeeddccbbaa')); + expect(parsed.version, equals(4)); + }); + }); + + group('ImageFragmentAck', () { + test('encodes and parses binary ack', () { + final ack = ImageFragmentAck(sessionId: '01020304', index: 9); + final payload = ack.encodeBinary(); + expect(ImageFragmentAck.isImageFragmentAckBinary(payload), isTrue); + final parsed = ImageFragmentAck.tryParseBinary(payload); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('01020304')); + expect(parsed.index, equals(9)); + }); + }); + + group('safeImageDataBytesForPath', () { + test('caps direct-route fragments to conservative default size', () { + expect(safeImageDataBytesForPath(0), equals(ImagePacket.maxDataBytes)); + }); + + test('shrinks for longer paths but never exceeds conservative default', () { + expect( + safeImageDataBytesForPath(2), + lessThanOrEqualTo(ImagePacket.maxDataBytes), + ); + }); + }); +} diff --git a/test/utils/link_quality_test.dart b/test/utils/link_quality_test.dart new file mode 100644 index 0000000..6858a73 --- /dev/null +++ b/test/utils/link_quality_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/link_quality.dart'; + +void main() { + group('linkQualityLabel', () { + test('classifies SNR-only values without forcing them to weak', () { + expect(linkQualityLabel(null, 12.0), 'Excellent'); + expect(linkQualityLabel(null, 6.0), 'Good'); + expect(linkQualityLabel(null, 1.0), 'Fair'); + expect(linkQualityLabel(null, -6.0), 'Weak'); + }); + + test('classifies RSSI-only values using direct thresholds', () { + expect(linkQualityLabel(-58, null), 'Excellent'); + expect(linkQualityLabel(-68, null), 'Good'); + expect(linkQualityLabel(-78, null), 'Fair'); + expect(linkQualityLabel(-92, null), 'Weak'); + }); + + test('averages mixed metrics when both are available', () { + expect(linkQualityLabel(-72, 11.0), 'Good'); + expect(linkQualityLabel(-85, 7.0), 'Fair'); + }); + }); +} diff --git a/test/utils/location_formats_test.dart b/test/utils/location_formats_test.dart new file mode 100644 index 0000000..2c902af --- /dev/null +++ b/test/utils/location_formats_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/location_formats.dart'; + +void main() { + group('formatUtm', () { + test('formats northern hemisphere UTM coordinates', () { + expect(formatUtm(37.7749, -122.4194), '10S 551131E 4180999N'); + }); + + test('formats southern hemisphere UTM coordinates', () { + expect(formatUtm(-33.8688, 151.2093), '56H 334369E 6250948N'); + }); + + test('falls back to decimal outside the UTM latitude range', () { + expect(formatUtm(85, 14.5), '85.00000, 14.50000'); + }); + }); + + group('formatCoordinates', () { + test('formats DMS coordinates', () { + expect( + formatCoordinates( + 46.0569, + 14.5058, + CoordinateDisplayFormat.dms, + ), + '46°03\'24.84"N 14°30\'20.88"E', + ); + }); + }); +} diff --git a/test/utils/log_rx_route_decoder_test.dart b/test/utils/log_rx_route_decoder_test.dart new file mode 100644 index 0000000..1117507 --- /dev/null +++ b/test/utils/log_rx_route_decoder_test.dart @@ -0,0 +1,179 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/utils/log_rx_route_decoder.dart'; + +void main() { + group('LogRxRouteDecoder.decode', () { + test('parses route and sender from LOG_RX_DATA packet', () { + final packet = Uint8List.fromList([ + 0x88, + 0x37, + 0xae, + 0x05, + 0x04, + 0xc2, + 0xba, + 0x5f, + 0xde, + 0x5c, + ]); + + final decoded = LogRxRouteDecoder.decode(packet); + + expect(decoded, isNotNull); + expect(decoded!.payloadType, 0x01); + expect(decoded.pathDescriptor, 0x04); + expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]); + expect(decoded.hashSize, 1); + expect(decoded.hopHashes, ['c2', 'ba', '5f', 'de']); + expect(decoded.originalSenderHashHex, 'c2'); + }); + + test('parses legacy two byte paths as two one-byte hops', () { + final packet = Uint8List.fromList([ + 0x88, + 0x37, + 0xae, + 0x05, + 0x02, + 0xc2, + 0xba, + ]); + + final decoded = LogRxRouteDecoder.decode(packet); + + expect(decoded, isNotNull); + expect(decoded!.pathDescriptor, 0x02); + expect(decoded.pathBytes, [0xc2, 0xba]); + expect(decoded.hashSize, 1); + expect(decoded.hopCount, 2); + expect(decoded.hopHashes, ['c2', 'ba']); + }); + + test('parses encoded descriptor with 2-byte hashes', () { + final packet = Uint8List.fromList([ + 0x88, + 0x37, + 0xae, + 0x05, + 0x42, + 0xc2, + 0xba, + 0x5f, + 0xde, + 0x5c, + ]); + + final decoded = LogRxRouteDecoder.decode(packet); + + expect(decoded, isNotNull); + expect(decoded!.pathDescriptor, 0x42); + expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]); + expect(decoded.hashSize, 2); + expect(decoded.hopCount, 2); + expect(decoded.hopHashes, ['c2ba', '5fde']); + }); + + test('uses one byte hashes for legacy packet lengths', () { + final packet = Uint8List.fromList([ + 0x88, + 0x37, + 0xae, + 0x09, + 0x06, + 0xaa, + 0xbb, + 0xcc, + 0xdd, + 0xee, + 0xff, + ]); + + final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 2); + + expect(decoded, isNotNull); + expect(decoded!.hashSize, 1); + expect(decoded.hopHashes, ['aa', 'bb', 'cc', 'dd', 'ee', 'ff']); + }); + }); + + group('LogRxRouteDecoder.resolveHash', () { + test('prefers own node when hash matches device key', () { + final resolved = LogRxRouteDecoder.resolveHash( + 'c201', + contacts: const [], + ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]), + ownName: 'Base', + ); + + expect(resolved.isOwnNode, isTrue); + expect(resolved.label, 'Base (you)'); + }); + + test('resolves unique contact by first public key byte', () { + final resolved = LogRxRouteDecoder.resolveHash( + 'c211', + contacts: [ + _contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]), + ], + ); + + expect(resolved.isUniqueMatch, isTrue); + expect(resolved.label, 'Alpha'); + }); + + test('marks ambiguous matches without pretending certainty', () { + final resolved = LogRxRouteDecoder.resolveHash( + 'c2', + contacts: [ + _contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]), + _contact(name: 'Bravo', keyPrefix: [0xc2, 0x22]), + ], + ); + + expect(resolved.isUniqueMatch, isFalse); + expect(resolved.matchCount, 2); + }); + }); + + group('LogRxRouteDecoder.reverseHopBytes', () { + test('reverses path by hop size', () { + final reversed = LogRxRouteDecoder.reverseHopBytes([ + 0xc2, + 0xba, + 0x5f, + 0xde, + 0xaa, + 0xbb, + ], hashSize: 2); + + expect(reversed, [0xaa, 0xbb, 0x5f, 0xde, 0xc2, 0xba]); + }); + }); +} + +Contact _contact({required String name, required List keyPrefix}) { + return Contact( + publicKey: Uint8List.fromList([ + ...keyPrefix, + 0x11, + 0x22, + 0x33, + 0x44, + 0x55, + 0x66, + 0x77, + ]), + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(0), + advName: name, + lastAdvert: 0, + advLat: 0, + advLon: 0, + lastMod: 0, + ); +} diff --git a/test/utils/media_swarm_protocol_test.dart b/test/utils/media_swarm_protocol_test.dart new file mode 100644 index 0000000..2bdc505 --- /dev/null +++ b/test/utils/media_swarm_protocol_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/media_swarm_protocol.dart'; + +void main() { + group('MediaSwarmProtocol', () { + test('encodes and decodes binary missing-fragment requests', () { + const request = MediaSwarmRequest( + mediaType: 'image', + sessionId: 'deadbeef', + requesterKey6: 'aabbccddeeff', + missingIndices: [9, 2, 9, 0], + ); + + final decoded = MediaSwarmRequest.tryParseBinary(request.encodeBinary()); + + expect(decoded, isNotNull); + expect(decoded!.mediaType, 'image'); + expect(decoded.sessionId, 'deadbeef'); + expect(decoded.requesterKey6, 'aabbccddeeff'); + expect(decoded.missingIndices, [0, 2, 9]); + expect(decoded.requestsAll, isFalse); + }); + + test('encodes and decodes binary availability advertisements', () { + const availability = MediaSwarmAvailability( + mediaType: 'voice', + sessionId: '01020304', + requesterKey6: 'aabbccddeeff', + responderKey6: '112233445566', + availableIndices: [7, 1], + ); + + final decoded = MediaSwarmAvailability.tryParseBinary( + availability.encodeBinary(), + ); + + expect(decoded, isNotNull); + expect(decoded!.mediaType, 'voice'); + expect(decoded.sessionId, '01020304'); + expect(decoded.requesterKey6, 'aabbccddeeff'); + expect(decoded.responderKey6, '112233445566'); + expect(decoded.availableIndices, [1, 7]); + expect(decoded.servesAll, isFalse); + }); + + test('uses zero-count semantics when no indices are provided', () { + const request = MediaSwarmRequest( + mediaType: 'voice', + sessionId: '01020304', + requesterKey6: 'aabbccddeeff', + ); + const availability = MediaSwarmAvailability( + mediaType: 'image', + sessionId: 'deadbeef', + requesterKey6: 'aabbccddeeff', + responderKey6: '112233445566', + availableIndices: [], + ); + + expect( + MediaSwarmRequest.tryParseBinary(request.encodeBinary())?.requestsAll, + isTrue, + ); + expect( + MediaSwarmAvailability.tryParseBinary( + availability.encodeBinary(), + )?.servesAll, + isTrue, + ); + }); + }); +} diff --git a/test/utils/sar_message_parser_test.dart b/test/utils/sar_message_parser_test.dart new file mode 100644 index 0000000..32dcb29 --- /dev/null +++ b/test/utils/sar_message_parser_test.dart @@ -0,0 +1,442 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_sar_app/models/sar_marker.dart'; +import 'package:meshcore_sar_app/models/map_coordinate_space.dart'; +import 'package:meshcore_sar_app/utils/sar_message_parser.dart'; + +void main() { + group('SarMessageParser - Critical String Formatting Tests', () { + test('createSarMessage returns raw string, not Object', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: LatLng(37.7749, -122.4194), + colorIndex: 2, + ); + + // CRITICAL: Must be a String type + expect(message, isA()); + + // CRITICAL: Must NOT contain "Instance of" or "Object" + expect(message, isNot(contains('Instance of'))); + expect(message, isNot(contains('Object'))); + expect(message, isNot(contains('LatLng'))); + + // CRITICAL: Must start with S: prefix + expect(message, startsWith('S:')); + }); + + test('createSarMessage produces correct format with all components', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: LatLng(37.7749, -122.4194), + notes: 'Found alive', + colorIndex: 2, + ); + + // New format: S:::,: + expect(message, startsWith('S:')); + expect(message, contains('🧑')); // or 👤 + expect(message, contains(':2:')); // color index + expect(message, contains('37.7749')); + expect(message, contains('-122.4194')); + expect(message, contains('Found alive')); + }); + + test('coordinates are converted to strings properly', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.fire, + location: LatLng(40.7128, -74.0060), + colorIndex: 0, + ); + + // CRITICAL: Coordinates must be in string form, not Object + expect(message, contains('40.7128')); + expect( + message, + contains('-74.006'), + ); // Trailing zero trimmed by toString() + + // Must NOT contain object representation + expect(message, isNot(contains('LatLng'))); + expect(message, isNot(contains('latitude:'))); + expect(message, isNot(contains('longitude:'))); + }); + + test('colorIndex is converted to string in format', () { + for (int i = 0; i <= 7; i++) { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.stagingArea, + location: LatLng(0, 0), + colorIndex: i, + ); + + // Color index should be in string + expect(message, contains(':$i:')); + expect(message, isA()); + } + }); + + test('null colorIndex defaults to 0', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: LatLng(1, 1), + colorIndex: null, + ); + + // Should default to color index 0 + expect(message, contains(':0:')); + }); + + test('isSarMessage correctly identifies SAR messages', () { + expect(SarMessageParser.isSarMessage('S:🧑:2:37.7,-122.4'), isTrue); + expect(SarMessageParser.isSarMessage('S:🔥:0:40.7,-74.0:Fire'), isTrue); + expect(SarMessageParser.isSarMessage('D:{"t":0}'), isFalse); + expect(SarMessageParser.isSarMessage('Plain text'), isFalse); + }); + + test('parse correctly extracts all components', () { + final message = 'S:🧑:2:37.7749,-122.4194:Found alive'; + final info = SarMessageParser.parse(message); + + expect(info, isNotNull); + expect(info!.type, equals(SarMarkerType.foundPerson)); + expect(info.location.latitude, equals(37.7749)); + expect(info.location.longitude, equals(-122.4194)); + expect(info.colorIndex, equals(2)); + expect(info.notes, contains('Found alive')); + }); + + test('parse handles old format without color index', () { + // Old format: S::,: + final message = 'S:🔥:40.7128,-74.0060:Large fire'; + final info = SarMessageParser.parse(message); + + expect(info, isNotNull); + expect(info!.type, equals(SarMarkerType.fire)); + expect(info.location.latitude, equals(40.7128)); + expect(info.location.longitude, equals(-74.0060)); + expect(info.colorIndex, isNull); // Old format has no color index + expect(info.notes, equals('Large fire')); + }); + + test('custom-map SAR markers use S2 format and preserve map metadata', () { + final message = SarMessageParser.createCustomMapSarMessage( + emoji: '📦', + mapId: 'abcdef1234567890', + point: LatLng(250, 400), + notes: 'Cache location', + colorIndex: 4, + ); + + expect(message, startsWith('S2:')); + + final parsed = SarMessageParser.parse(message); + expect(parsed, isNotNull); + expect(parsed!.coordinateSpace, MapCoordinateSpace.customMap); + expect(parsed.mapId, 'abcdef123456'); + expect(parsed.location.latitude, 250); + expect(parsed.location.longitude, 400); + expect(parsed.notes, 'Cache location'); + }); + + test('round-trip: create -> parse -> create preserves format', () { + final original = SarMessageParser.createSarMessage( + type: SarMarkerType.stagingArea, + location: LatLng(51.5074, -0.1278), + notes: 'Command center', + colorIndex: 4, + ); + + // Parse it + final parsed = SarMessageParser.parse(original); + expect(parsed, isNotNull); + + // Create again + final recreated = SarMessageParser.createSarMessage( + type: parsed!.type, + location: parsed.location, + notes: parsed.notes, + colorIndex: parsed.colorIndex, + ); + + // Should be identical + expect(recreated, equals(original)); + }); + + test('negative coordinates are handled correctly', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: LatLng(-33.8688, -151.2093), // Sydney (negative coords) + colorIndex: 1, + ); + + expect(message, contains('-33.8688')); + expect(message, contains('-151.2093')); + + // Should be parseable + final parsed = SarMessageParser.parse(message); + expect(parsed, isNotNull); + expect(parsed!.location.latitude, equals(-33.8688)); + expect(parsed.location.longitude, equals(-151.2093)); + }); + + test('extreme valid coordinates work correctly', () { + // Test near max valid latitude/longitude + final message1 = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: LatLng(89.99999, 179.99999), + colorIndex: 0, + ); + + expect(message1, contains('89.99999')); + expect(message1, contains('179.99999')); + + // Test near min valid latitude/longitude + final message2 = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: LatLng(-89.99999, -179.99999), + colorIndex: 0, + ); + + expect(message2, contains('-89.99999')); + expect(message2, contains('-179.99999')); + }); + + test('parse rejects invalid coordinates', () { + expect(SarMessageParser.parse('S:🧑:0:91.0,0'), isNull); // lat > 90 + expect(SarMessageParser.parse('S:🧑:0:-91.0,0'), isNull); // lat < -90 + expect(SarMessageParser.parse('S:🧑:0:0,181.0'), isNull); // lon > 180 + expect(SarMessageParser.parse('S:🧑:0:0,-181.0'), isNull); // lon < -180 + }); + + test('parse rejects invalid color indices', () { + // Color index should be 0-7 + final message = 'S:🧑:9:37.7,-122.4'; // Invalid index 9 + final info = SarMessageParser.parse(message); + + expect(info, isNotNull); + expect(info!.colorIndex, isNull); // Should be ignored if invalid + }); + + test('notes with special characters are preserved', () { + final notes = 'Multi-line\nwith: colons, commas'; + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: LatLng(1, 2), + notes: notes, + colorIndex: 0, + ); + + final parsed = SarMessageParser.parse(message); + expect(parsed, isNotNull); + expect(parsed!.notes, contains('Multi-line')); + expect(parsed.notes, contains('colons, commas')); + }); + + test('empty notes produce valid message', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.fire, + location: LatLng(1, 2), + notes: '', + colorIndex: 0, + ); + + // Should not have trailing colon + expect(message, isNot(endsWith(':'))); + + // Should be parseable + final parsed = SarMessageParser.parse(message); + expect(parsed, isNotNull); + }); + + test('null notes produce valid message', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.stagingArea, + location: LatLng(1, 2), + notes: null, + colorIndex: 0, + ); + + // Should not have notes section + expect(message, isNot(endsWith(':'))); + + // Should be parseable + final parsed = SarMessageParser.parse(message); + expect(parsed, isNotNull); + }); + + test('all emoji types produce valid strings', () { + final types = [ + SarMarkerType.foundPerson, + SarMarkerType.fire, + SarMarkerType.stagingArea, + ]; + + for (final type in types) { + final message = SarMessageParser.createSarMessage( + type: type, + location: LatLng(1, 2), + colorIndex: 0, + ); + + expect(message, isA()); + expect(message, startsWith('S:')); + expect(message, contains(type.emoji)); + + // Must not contain type name or object representation + expect(message, isNot(contains('SarMarkerType'))); + expect(message, isNot(contains('Instance'))); + } + }); + + test('isValidFormat correctly validates messages', () { + expect( + SarMessageParser.isValidFormat('S:🧑:2:37.7749,-122.4194'), + isTrue, + ); + + expect(SarMessageParser.isValidFormat('S:invalid:format'), isFalse); + + expect(SarMessageParser.isValidFormat('Not SAR message'), isFalse); + }); + + test('getFormatError provides helpful error messages', () { + expect( + SarMessageParser.getFormatError('Not SAR'), + contains('must start with "S:"'), + ); + + expect(SarMessageParser.getFormatError('S:'), contains('Invalid format')); + + expect( + SarMessageParser.getFormatError('S::37.7,-122.4'), + contains('Missing emoji'), + ); + }); + + test('extractNotes correctly handles multi-line messages', () { + final message = 'S:🧑:0:37.7,-122.4:First line\nSecond line\nThird line'; + final notes = SarMessageParser.extractNotes(message); + + expect(notes, isNotNull); + expect(notes, contains('Second line')); + expect(notes, contains('Third line')); + }); + + test('message format is compact and efficient', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: LatLng(37.7749, -122.4194), + colorIndex: 2, + ); + + // Should be very compact: S:🧑:2:37.7749,-122.4194 + expect(message.length, lessThan(50)); + + // Should not have extra whitespace + expect(message, isNot(contains(' '))); + expect(message, isNot(contains('\n'))); + }); + + test('coordinates maintain precision', () { + final precise = LatLng(37.774901234, -122.419401234); + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: precise, + colorIndex: 0, + ); + + final parsed = SarMessageParser.parse(message); + expect(parsed, isNotNull); + + // Should maintain reasonable precision (Dart's toString default) + expect(parsed!.location.latitude, closeTo(37.774901234, 0.000001)); + expect(parsed.location.longitude, closeTo(-122.419401234, 0.000001)); + }); + + test('zero coordinates work correctly', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.stagingArea, + location: LatLng(0.0, 0.0), + colorIndex: 0, + ); + + expect(message, contains('0.0,0.0')); + + final parsed = SarMessageParser.parse(message); + expect(parsed, isNotNull); + expect(parsed!.location.latitude, equals(0.0)); + expect(parsed.location.longitude, equals(0.0)); + }); + + test('emoji is preserved as string, not code points', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: LatLng(1, 2), + colorIndex: 0, + ); + + // Should contain actual emoji character + expect(message, contains('🧑')); + + // Should NOT contain unicode code points or escape sequences + expect(message, isNot(contains(r'\u'))); + expect(message, isNot(contains('U+'))); + }); + + test('format is compatible with CLAUDE.md specification', () { + // According to CLAUDE.md: + // New format: S:::,: + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.fire, + location: LatLng(40.7128, -74.0060), + notes: 'Large wildfire spreading rapidly', + colorIndex: 0, + ); + + // Should match: S:🔥:0:40.7128,-74.006:Large wildfire spreading rapidly + expect(message, startsWith('S:🔥:0:')); + expect(message, contains('40.7128,-74.006')); // Trailing zero trimmed + expect(message, endsWith('Large wildfire spreading rapidly')); + }); + + test('backward compatibility with old format', () { + // Old format without color index: S::,: + final oldMessage = 'S:🧑:37.7749,-122.4194:Found person'; + final parsed = SarMessageParser.parse(oldMessage); + + expect(parsed, isNotNull); + expect(parsed!.type, equals(SarMarkerType.foundPerson)); + expect(parsed.colorIndex, isNull); // Old format has no color index + expect(parsed.notes, equals('Found person')); + }); + + test('new format with color index is preferred', () { + final message = SarMessageParser.createSarMessage( + type: SarMarkerType.foundPerson, + location: LatLng(1, 2), + colorIndex: 3, + ); + + // Should use new format with color index + expect(message, contains(':3:')); + expect(message, contains('🧑')); + }); + + test('toString produces human-readable output for SarMarkerInfo', () { + final info = SarMarkerInfo( + type: SarMarkerType.foundPerson, + location: LatLng(37.7749, -122.4194), + emoji: '🧑', + notes: 'Test notes', + colorIndex: 2, + coordinateSpace: MapCoordinateSpace.geo, + ); + + final str = info.toString(); + expect(str, contains('SarMarkerInfo')); + expect(str, contains('Found Person')); // Display name, not enum name + expect(str, contains('colorIndex: 2')); + }); + }); +} diff --git a/test/utils/voice_message_parser_test.dart b/test/utils/voice_message_parser_test.dart new file mode 100644 index 0000000..ead89d3 --- /dev/null +++ b/test/utils/voice_message_parser_test.dart @@ -0,0 +1,192 @@ +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; + +void main() { + group('VoiceEnvelope', () { + test('encodes and parses valid envelope', () { + final env = VoiceEnvelope( + sessionId: '0000000a', + mode: VoicePacketMode.mode1200, + total: 4, + durationMs: 3000, + ); + + final text = env.encodeText(); + expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue); + expect(text.startsWith('VE3:'), isTrue); + expect(text.split(':')[1], equals('a')); + + final parsed = VoiceEnvelope.tryParseText(text); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('0000000a')); + expect(parsed.mode, equals(VoicePacketMode.mode1200)); + expect(parsed.total, equals(4)); + expect(parsed.durationMs, equals(3000)); + expect(parsed.version, equals(3)); + }); + + test('rejects invalid envelope payload', () { + final text = 'VE3:bad_sid:1:2:1000'; + expect(VoiceEnvelope.tryParseText(text), isNull); + }); + + test('rejects legacy v1 envelope prefix', () { + const legacy = 'VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1'; + expect(VoiceEnvelope.tryParseText(legacy), isNull); + }); + }); + + group('VoiceFetchRequest', () { + test('encodes and parses valid request', () { + final req = VoiceFetchRequest( + sessionId: '0000000a', + requesterKey6: 'ffeeddccbbaa', + ); + final text = req.encodeText(); + expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue); + expect(text.startsWith('VR3:'), isTrue); + expect(text.split(':')[1], equals('a')); + + final parsed = VoiceFetchRequest.tryParseText(text); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('0000000a')); + expect(parsed.want, equals('all')); + expect(parsed.requesterKey6, equals('ffeeddccbbaa')); + expect(parsed.version, equals(3)); + }); + + test('rejects invalid request payload', () { + expect( + VoiceFetchRequest.tryParseText( + 'VR3:a:chunk:ffeeddccbbaa', + ), + isNull, + ); + }); + + test('rejects legacy v1 request prefix', () { + const legacy = 'VR1:00112233:a:ffeeddccbbaa:1700000001:1'; + expect(VoiceFetchRequest.tryParseText(legacy), isNull); + }); + + test('encodes and parses missing-packet request', () { + final req = VoiceFetchRequest( + sessionId: '0000000a', + want: 'missing', + missingIndices: const [0, 1, 2, 3, 7], + requesterKey6: 'ffeeddccbbaa', + ); + final text = req.encodeText(); + expect(text, contains(':m0-3.7:')); + + final parsed = VoiceFetchRequest.tryParseText(text); + expect(parsed, isNotNull); + expect(parsed!.want, equals('missing')); + expect(parsed.missingIndices, equals([0, 1, 2, 3, 7])); + }); + + test('encodes and parses binary fetch request', () { + final req = VoiceFetchRequest( + sessionId: '01020304', + want: 'missing', + missingIndices: const [1, 4], + requesterKey6: 'ffeeddccbbaa', + ); + + final payload = req.encodeBinary(); + expect(VoiceFetchRequest.isVoiceFetchRequestBinary(payload), isTrue); + + final parsed = VoiceFetchRequest.tryParseBinary(payload); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('01020304')); + expect(parsed.want, equals('missing')); + expect(parsed.missingIndices, equals([1, 4])); + expect(parsed.requesterKey6, equals('ffeeddccbbaa')); + expect(parsed.version, equals(3)); + }); + }); + + group('VoiceFragmentAck', () { + test('encodes and parses binary ack', () { + final ack = VoiceFragmentAck(sessionId: '01020304', index: 7); + final payload = ack.encodeBinary(); + expect(VoiceFragmentAck.isVoiceFragmentAckBinary(payload), isTrue); + final parsed = VoiceFragmentAck.tryParseBinary(payload); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('01020304')); + expect(parsed.index, equals(7)); + }); + }); + + group('VoicePacket binary format', () { + test('constructs binary datagram from actual packet data', () { + final actualCodec2 = Uint8List.fromList([ + 0xD3, + 0x19, + 0x7A, + 0x00, + 0xFE, + 0x44, + 0xC1, + 0x2B, + 0x88, + ]); + + final pkt = VoicePacket( + sessionId: '01020304', + mode: VoicePacketMode.mode1300, + index: 2, + total: 5, + codec2Data: actualCodec2, + ); + + final datagram = pkt.encodeBinary(); + expect(datagram[0], equals(0x56)); // magic 'V' + expect(datagram.sublist(1, 5), equals(Uint8List.fromList([1, 2, 3, 4]))); + expect(datagram[5], equals(2)); + expect(datagram.sublist(6), equals(actualCodec2)); + + final parsed = VoicePacket.tryParseBinary(datagram); + expect(parsed, isNotNull); + expect(parsed!.sessionId, equals('01020304')); + expect(parsed.mode, equals(VoicePacketMode.mode1300)); + expect(parsed.index, equals(2)); + expect(parsed.total, equals(0)); + expect(parsed.codec2Data, equals(actualCodec2)); + }); + }); + + group('VoiceWaveform', () { + test('builds bars from packet bytes', () { + final packet = VoicePacket( + sessionId: '1234abcd', + mode: VoicePacketMode.mode1200, + index: 0, + total: 1, + codec2Data: Uint8List.fromList([ + 0, + 255, + 10, + 245, + 120, + 130, + 64, + 192, + 32, + 224, + ]), + ); + + final bars = VoiceWaveform.buildBarsFromPackets([packet], bars: 8); + expect(bars.length, equals(8)); + expect(bars.every((v) => v >= 0.0 && v <= 1.0), isTrue); + expect(bars.any((v) => v > 0.2), isTrue); + }); + + test('returns zeros for missing packet data', () { + final bars = VoiceWaveform.buildBarsFromPackets(const [], bars: 6); + expect(bars, equals(List.filled(6, 0.0))); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..3de035b --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,33 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:meshcore_sar_app/main.dart'; +import 'package:meshcore_sar_app/screens/home_screen.dart'; +import 'package:meshcore_sar_app/screens/welcome_wizard_screen.dart'; + +void main() { + testWidgets('App launches smoke test', (WidgetTester tester) async { + // Build app. + await tester.pumpWidget(const MeshCoreSarApp()); + + // During async initialization, loading indicator is shown. + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Give async startup a bounded amount of time without requiring full settle + // (the app may keep background animations/timers alive). + await tester.pump(const Duration(seconds: 2)); + expect( + find.byType(CircularProgressIndicator).evaluate().isNotEmpty || + find.byType(HomeScreen).evaluate().isNotEmpty || + find.byType(WelcomeWizardScreen).evaluate().isNotEmpty, + isTrue, + ); + }); +} diff --git a/test/widgets/add_channel_dialog_test.dart b/test/widgets/add_channel_dialog_test.dart new file mode 100644 index 0000000..cf4c135 --- /dev/null +++ b/test/widgets/add_channel_dialog_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/widgets/contacts/add_channel_dialog.dart'; + +void main() { + Future pumpSheet( + WidgetTester tester, { + required Future Function(String name, String secret) onCreateChannel, + }) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: AddChannelSheet(onCreateChannel: onCreateChannel), + ), + ), + ), + ), + ); + } + + testWidgets('submits hash channels with an empty secret', (tester) async { + String? submittedName; + String? submittedSecret; + + await pumpSheet( + tester, + onCreateChannel: (name, secret) async { + submittedName = name; + submittedSecret = secret; + }, + ); + + await tester.enterText(find.byType(TextFormField).first, '#slovenia'); + await tester.pump(); + await tester.tap(find.text('Create Channel')); + await tester.pumpAndSettle(); + + expect(submittedName, '#slovenia'); + expect(submittedSecret, ''); + }); + + testWidgets('uses done action for hash channels', (tester) async { + String? submittedName; + + await pumpSheet( + tester, + onCreateChannel: (name, secret) async { + submittedName = name; + }, + ); + + await tester.enterText(find.byType(TextFormField).first, '#slovenia'); + await tester.pump(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(submittedName, '#slovenia'); + }); +} diff --git a/test/widgets/bidirectional_refresh_test.dart b/test/widgets/bidirectional_refresh_test.dart new file mode 100644 index 0000000..e6b8918 --- /dev/null +++ b/test/widgets/bidirectional_refresh_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/widgets/common/bidirectional_refresh.dart'; + +void main() { + Future pumpRefreshHarness( + WidgetTester tester, { + required Future Function() onRefresh, + }) { + late BuildContext childContext; + + return tester + .pumpWidget( + MaterialApp( + home: Scaffold( + body: BidirectionalRefresh( + onRefresh: onRefresh, + child: Builder( + builder: (context) { + childContext = context; + return const SizedBox.expand(); + }, + ), + ), + ), + ), + ) + .then((_) => childContext); + } + + OverscrollNotification topOverscroll(BuildContext context) { + return OverscrollNotification( + metrics: FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 500, + pixels: 0, + viewportDimension: 300, + axisDirection: AxisDirection.down, + devicePixelRatio: 1, + ), + context: context, + overscroll: -120, + ); + } + + OverscrollNotification bottomOverscroll(BuildContext context) { + return OverscrollNotification( + metrics: FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 500, + pixels: 500, + viewportDimension: 300, + axisDirection: AxisDirection.down, + devicePixelRatio: 1, + ), + context: context, + overscroll: 120, + ); + } + + testWidgets('refresh triggers from top overscroll', (tester) async { + var refreshCount = 0; + final childContext = await pumpRefreshHarness( + tester, + onRefresh: () async { + refreshCount++; + }, + ); + + topOverscroll(childContext).dispatch(childContext); + await tester.pump(); + + expect(refreshCount, 1); + }); + + testWidgets('refresh triggers from bottom overscroll', (tester) async { + var refreshCount = 0; + final childContext = await pumpRefreshHarness( + tester, + onRefresh: () async { + refreshCount++; + }, + ); + + bottomOverscroll(childContext).dispatch(childContext); + await tester.pump(); + + expect(refreshCount, 1); + }); +} diff --git a/test/widgets/compass_math_test.dart b/test/widgets/compass_math_test.dart new file mode 100644 index 0000000..61d1c98 --- /dev/null +++ b/test/widgets/compass_math_test.dart @@ -0,0 +1,36 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/widgets/map/compass/compass_math.dart'; + +void main() { + group('compass dial math', () { + test('rotates the rose opposite the heading', () { + expect(compassRoseRotationRadians(0), closeTo(0, 1e-10)); + expect(compassRoseRotationRadians(90), closeTo(-pi / 2, 1e-10)); + expect(compassRoseRotationRadians(450), closeTo(-pi / 2, 1e-10)); + }); + + test('places east at the top when heading east', () { + expect( + compassDialAngleRadians(markerDegrees: 90, headingDegrees: 90), + closeTo(-pi / 2, 1e-10), + ); + expect( + compassDialAngleRadians(markerDegrees: 270, headingDegrees: 90), + closeTo(pi / 2, 1e-10), + ); + }); + + test('places west at the top when heading west', () { + expect( + compassDialAngleRadians(markerDegrees: 270, headingDegrees: 270), + closeTo(-pi / 2, 1e-10), + ); + expect( + compassDialAngleRadians(markerDegrees: 90, headingDegrees: 270), + closeTo(pi / 2, 1e-10), + ); + }); + }); +} diff --git a/test/widgets/connection_dialog_test.dart b/test/widgets/connection_dialog_test.dart new file mode 100644 index 0000000..41f5bdd --- /dev/null +++ b/test/widgets/connection_dialog_test.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/services/network_scanner_service.dart'; +import 'package:meshcore_sar_app/widgets/connection_dialog.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeConnectionProvider extends ConnectionProvider { + int startScanCalls = 0; + int stopScanCalls = 0; + bool _isScanning = false; + + @override + bool get isScanning => _isScanning; + + @override + List get scannedDevices => const []; + + @override + String? get error => null; + + @override + Future startScan() async { + startScanCalls += 1; + _isScanning = true; + notifyListeners(); + } + + @override + Future stopScan() async { + stopScanCalls += 1; + _isScanning = false; + notifyListeners(); + } +} + +class _ConnectableFakeConnectionProvider extends ConnectionProvider { + int connectCalls = 0; + + @override + List get scannedDevices => [ + ScannedDevice(device: BluetoothDevice.fromId('test-device'), rssi: -55), + ]; + + @override + String? get error => null; + + @override + Future connect(BluetoothDevice device) async { + connectCalls += 1; + return true; + } +} + +class _TcpConnectableFakeConnectionProvider extends ConnectionProvider { + int connectTcpCalls = 0; + String? connectedHost; + int? connectedPort; + + @override + List get scannedDevices => const []; + + @override + String? get error => null; + + @override + Future connectTcp(String host, int port) async { + connectTcpCalls += 1; + connectedHost = host; + connectedPort = port; + return true; + } +} + +class _FakeNetworkScannerService extends NetworkScannerService { + _FakeNetworkScannerService({ + this.keepScanning = false, + bool initiallyScanning = false, + }) : _isScanning = initiallyScanning; + + final bool keepScanning; + int scanCalls = 0; + int stopScanCalls = 0; + bool _isScanning; + + @override + bool get isScanning => _isScanning; + + @override + bool get hasCachedResults => false; + + @override + List get cachedServers => const []; + + @override + Future> scan({int? port}) async { + scanCalls += 1; + _isScanning = keepScanning; + if (keepScanning) { + onProgressUpdate?.call(1, 10); + } + return const []; + } + + @override + void clearCache() {} + + @override + void stopScan() { + stopScanCalls += 1; + _isScanning = false; + } +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('BLE scan waits for explicit user action', (tester) async { + final connectionProvider = _FakeConnectionProvider(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: connectionProvider, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: ConnectionDialog()), + ), + ), + ); + + await tester.pump(); + + expect(connectionProvider.startScanCalls, 0); + expect( + find.text('Press scan to search for nearby devices'), + findsOneWidget, + ); + expect(find.text('Scan'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Scan')); + await tester.pump(); + + expect(connectionProvider.stopScanCalls, 1); + expect(connectionProvider.startScanCalls, 1); + }); + + testWidgets('successful BLE connect closes the dialog immediately', ( + tester, + ) async { + final connectionProvider = _ConnectableFakeConnectionProvider(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: connectionProvider, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const ConnectionDialog(), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(ConnectionDialog), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(connectionProvider.connectCalls, 1); + expect(find.byType(ConnectionDialog), findsNothing); + }); + + testWidgets('manual TCP connect can cancel discovery and use a custom port', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final connectionProvider = _TcpConnectableFakeConnectionProvider(); + final networkScanner = _FakeNetworkScannerService( + initiallyScanning: true, + keepScanning: true, + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: connectionProvider, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ConnectionDialog( + networkScanner: networkScanner, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Network')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final manualEntryButton = find.widgetWithText( + TextButton, + 'Cancel and enter manually', + ); + final onPressed = tester.widget(manualEntryButton).onPressed; + expect(onPressed, isNotNull); + onPressed!(); + await tester.pumpAndSettle(); + + expect(networkScanner.stopScanCalls, greaterThanOrEqualTo(1)); + + await tester.enterText(find.widgetWithText(TextField, 'IP address'), '192.168.1.42'); + await tester.enterText(find.widgetWithText(TextField, 'TCP port'), '6001'); + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(connectionProvider.connectTcpCalls, 1); + expect(connectionProvider.connectedHost, '192.168.1.42'); + expect(connectionProvider.connectedPort, 6001); + expect(find.byType(ConnectionDialog), findsNothing); + }); + + testWidgets('recent servers show saved metadata and can reconnect', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'recent_tcp_connections_v1': [ + '{"name":"Mesh Node Alpha","host":"10.0.0.5","port":5001,"lastUsedAt":"2026-04-18T12:00:00.000Z"}', + ], + }); + + final connectionProvider = _TcpConnectableFakeConnectionProvider(); + final networkScanner = _FakeNetworkScannerService(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: connectionProvider, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ConnectionDialog( + networkScanner: networkScanner, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Network')); + await tester.pumpAndSettle(); + + expect(find.text('Recently used'), findsOneWidget); + expect(find.text('Mesh Node Alpha'), findsOneWidget); + expect(find.text('10.0.0.5:5001'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Connect').first); + await tester.pumpAndSettle(); + + expect(connectionProvider.connectTcpCalls, 1); + expect(connectionProvider.connectedHost, '10.0.0.5'); + expect(connectionProvider.connectedPort, 5001); + }); +} diff --git a/test/widgets/contact_avatar_test.dart b/test/widgets/contact_avatar_test.dart new file mode 100644 index 0000000..842524e --- /dev/null +++ b/test/widgets/contact_avatar_test.dart @@ -0,0 +1,123 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart'; + +void main() { + Contact buildContact({ + required String name, + required ContactType type, + int secondByte = 0, + }) { + final publicKey = Uint8List(32); + publicKey[1] = secondByte; + + return Contact( + publicKey: publicKey, + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(0), + advName: name, + lastAdvert: 0, + advLat: 0, + advLon: 0, + lastMod: 0, + ); + } + + Future pumpAvatar(WidgetTester tester, Contact contact) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center(child: ContactAvatar(contact: contact)), + ), + ), + ); + } + + testWidgets('renders label avatar for rooms', (tester) async { + await pumpAvatar( + tester, + buildContact(name: 'Operations Room', type: ContactType.room), + ); + + expect(find.text('OR'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byIcon(Icons.meeting_room), findsNothing); + }); + + testWidgets('renders hash label avatar for rooms', (tester) async { + await pumpAvatar( + tester, + buildContact(name: '#ops-room', type: ContactType.room), + ); + + expect(find.text('#OP'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byIcon(Icons.meeting_room), findsNothing); + }); + + testWidgets('renders compact hash label avatar when name includes spacing', ( + tester, + ) async { + await pumpAvatar( + tester, + buildContact(name: '# foo alpha', type: ContactType.room), + ); + + expect(find.text('#FO'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byIcon(Icons.meeting_room), findsNothing); + }); + + testWidgets('renders label avatar for channels', (tester) async { + await pumpAvatar( + tester, + buildContact(name: '#ops', type: ContactType.channel, secondByte: 3), + ); + + expect(find.text('#OP'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byIcon(Icons.public), findsNothing); + }); + + testWidgets('renders non-hash label avatar for channels', (tester) async { + await pumpAvatar( + tester, + buildContact( + name: 'Command Net', + type: ContactType.channel, + secondByte: 3, + ), + ); + + expect(find.text('CN'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byIcon(Icons.public), findsNothing); + }); + + testWidgets('renders round avatar for chat contacts', (tester) async { + await pumpAvatar( + tester, + buildContact(name: 'John Smith', type: ContactType.chat), + ); + + expect(find.text('JS'), findsOneWidget); + expect(find.byType(CircleAvatar), findsOneWidget); + expect(find.byIcon(Icons.person), findsNothing); + }); + + testWidgets('renders sensor icon avatar for sensor contacts', (tester) async { + await pumpAvatar( + tester, + buildContact(name: 'WX Station', type: ContactType.sensor), + ); + + expect(find.byType(CircleAvatar), findsOneWidget); + expect(find.byIcon(Icons.sensors), findsOneWidget); + expect(find.text('WS'), findsNothing); + }); +} diff --git a/test/widgets/contact_tile_test.dart b/test/widgets/contact_tile_test.dart new file mode 100644 index 0000000..626a178 --- /dev/null +++ b/test/widgets/contact_tile_test.dart @@ -0,0 +1,263 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/providers/app_provider.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/map_provider.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:meshcore_sar_app/widgets/contacts/contact_tile.dart'; +import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeAppProvider extends ChangeNotifier implements AppProvider { + @override + ChannelLocationSharingMode? channelLocationSharingModeForChannel( + int channelIdx, + ) { + return null; + } + + @override + Future getChannelLocationSharingState( + int channelIdx, + ) async { + return const ChannelLocationSharingState( + mode: ChannelLocationSharingMode.appFallback, + isSharing: false, + hardwareSupported: false, + isConnected: false, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) { + return null; + } +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Contact buildContact({ + required String name, + required ContactType type, + int secondByte = 1, + }) { + final publicKey = Uint8List(32); + publicKey[1] = secondByte; + + return Contact( + publicKey: publicKey, + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 46562000, + advLon: 14950000, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + Future Function()> pumpTile( + WidgetTester tester, + Contact contact, { + SensorsProvider? sensorsProvider, + }) async { + final connectionProvider = ConnectionProvider(); + final contactsProvider = ContactsProvider(); + final messagesProvider = MessagesProvider(); + final mapProvider = MapProvider(); + final appProvider = _FakeAppProvider(); + final resolvedSensorsProvider = sensorsProvider ?? SensorsProvider(); + final ownsSensorsProvider = sensorsProvider == null; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: connectionProvider, + ), + ChangeNotifierProvider.value(value: contactsProvider), + ChangeNotifierProvider.value(value: messagesProvider), + ChangeNotifierProvider.value( + value: resolvedSensorsProvider, + ), + ChangeNotifierProvider.value(value: mapProvider), + ChangeNotifierProvider.value(value: appProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: ContactTile(contact: contact)), + ), + ), + ); + + return () async { + await tester.pumpWidget(const SizedBox.shrink()); + connectionProvider.dispose(); + if (ownsSensorsProvider) { + resolvedSensorsProvider.dispose(); + } + await tester.pump(); + }; + } + + Future withPumpedTile( + WidgetTester tester, + Contact contact, + Future Function() body, { + SensorsProvider? sensorsProvider, + }) async { + final dispose = await pumpTile( + tester, + contact, + sensorsProvider: sensorsProvider, + ); + try { + await body(); + } finally { + await dispose(); + } + } + + testWidgets('does not show diagnostic action for non-channel contacts', ( + tester, + ) async { + await withPumpedTile( + tester, + buildContact(name: 'John Smith', type: ContactType.chat), + () async { + await tester.tap(find.text('John Smith')); + await tester.pumpAndSettle(); + + expect(find.text('Trace'), findsNothing); + }, + ); + }); + + testWidgets('does not show diagnostic action for channels', (tester) async { + await withPumpedTile( + tester, + buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3), + () async { + await tester.tap(find.text('Ops')); + await tester.pumpAndSettle(); + + expect(find.text('Trace'), findsNothing); + }, + ); + }); + + testWidgets('shows overridden contact name as primary label', (tester) async { + await withPumpedTile( + tester, + buildContact( + name: 'John Smith', + type: ContactType.chat, + ).copyWith(nameOverride: 'Rescue One'), + () async { + expect(find.text('Rescue One'), findsOneWidget); + expect(find.text('John Smith'), findsNothing); + }, + ); + }); + + testWidgets('hides public key in contact tile', (tester) async { + final contact = buildContact(name: 'John Smith', type: ContactType.chat); + + await withPumpedTile(tester, contact, () async { + expect(find.text(contact.publicKeyShort), findsNothing); + expect(find.byIcon(Icons.key_outlined), findsNothing); + }); + }); + + testWidgets('sensor contacts can be added to sensors', (tester) async { + await withPumpedTile( + tester, + buildContact(name: 'WX Station', type: ContactType.sensor), + () async { + await tester.tap(find.text('WX Station')); + await tester.pumpAndSettle(); + + expect(find.text('Add to Sensors'), findsOneWidget); + }, + ); + }); + + testWidgets('sensor preview shows telemetry card', (tester) async { + final contact = buildContact(name: 'WX Station', type: ContactType.sensor) + .copyWith( + telemetry: ContactTelemetry( + batteryPercentage: 84, + temperature: 21.5, + humidity: 58.0, + extraSensorData: const { + '__source_channel:battery': 1, + '__source_channel:temperature': 1, + '__source_channel:humidity': 1, + 'co2': 415.0, + 'illuminance_2': 500.0, + 'current_2': 0.015, + 'power_2': 0.25, + 'distance_2': 1.234, + }, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + + await withPumpedTile(tester, contact, () async { + await tester.tap(find.text('WX Station')); + await tester.pumpAndSettle(); + + expect(find.text('Preview'), findsOneWidget); + + await tester.tap(find.text('Preview')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.text('Battery'), findsOneWidget); + expect(find.text('84%'), findsOneWidget); + expect(find.text('Temperature'), findsOneWidget); + expect(find.text('21.5°C'), findsOneWidget); + expect(find.text('CO2'), findsOneWidget); + expect(find.text('415 ppm'), findsOneWidget); + expect(find.text('Illuminance'), findsOneWidget); + expect(find.text('~4.2 W/m2'), findsOneWidget); + expect(find.textContaining('lx'), findsNothing); + expect(find.text('Current'), findsOneWidget); + expect(find.text('15 mA'), findsOneWidget); + expect(find.text('Power'), findsOneWidget); + expect(find.text('Distance'), findsOneWidget); + expect( + find.byKey(const ValueKey('sensor_metric_battery')), + findsOneWidget, + ); + expect(find.text('ch1'), findsWidgets); + expect( + find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')), + findsOneWidget, + ); + + final sensorCardSize = tester.getSize(find.byType(SensorTelemetryCard)); + final batteryTileSize = tester.getSize( + find.byKey(const ValueKey('sensor_metric_battery')), + ); + expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8)); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect(find.byType(SensorTelemetryCard), findsNothing); + }); + }); +} diff --git a/test/widgets/message_bubble_test.dart b/test/widgets/message_bubble_test.dart new file mode 100644 index 0000000..f8641aa --- /dev/null +++ b/test/widgets/message_bubble_test.dart @@ -0,0 +1,677 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/models/message_contact_location.dart'; +import 'package:meshcore_sar_app/models/path_selection.dart'; +import 'package:meshcore_sar_app/providers/app_provider.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/drawing_provider.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart' as ip; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/providers/voice_provider.dart'; +import 'package:meshcore_sar_app/services/location_tracking_service.dart'; +import 'package:meshcore_sar_app/services/voice_codec_service.dart'; +import 'package:meshcore_sar_app/services/voice_player_service.dart'; +import 'package:meshcore_sar_app/utils/message_extensions.dart'; +import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final launchedUrls = []; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + LocationTrackingService().currentPosition = null; + launchedUrls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + (call) async { + switch (call.method) { + case 'canLaunch': + return true; + case 'launch': + final arguments = Map.from( + call.arguments as Map, + ); + launchedUrls.add(arguments['url'] as String); + return true; + } + return null; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + null, + ); + }); + + testWidgets('received bubbles open details on double tap', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'received-signal', + messageType: MessageType.contact, + senderPublicKeyPrefix: _prefix(1), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Inbound message', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.received, + lastEchoRssiDbm: -84, + lastEchoSnrRaw: 24, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pump(const Duration(milliseconds: 60)); + + expect(find.text('1 hop'), findsNothing); + expect(find.text('Fair'), findsNothing); + expect(find.text('-84'), findsNothing); + expect(find.text('6.0'), findsNothing); + + await tester.tap(find.text('Inbound message')); + await tester.pump(kDoubleTapTimeout); + + expect(find.text('1 hop'), findsNothing); + expect(find.text('-84'), findsNothing); + + await _doubleTap(tester, find.text('Inbound message')); + await tester.pumpAndSettle(); + + expect(find.text('Message details'), findsOneWidget); + expect(find.text('1 hop'), findsNothing); + expect(find.text('Fair'), findsNothing); + expect(find.text('-84'), findsNothing); + expect(find.text('6.0'), findsNothing); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('delivered direct bubbles open details on double tap', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'sent-direct-signal', + messageType: MessageType.contact, + senderPublicKeyPrefix: _prefix(11), + recipientPublicKey: _key(77), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Outbound message', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.delivered, + roundTripTimeMs: 320, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pump(const Duration(milliseconds: 60)); + + expect(find.text('Direct'), findsNothing); + expect(find.text('320ms'), findsNothing); + + await tester.tap(find.text('Outbound message')); + await tester.pump(kDoubleTapTimeout); + + expect(find.text('Direct'), findsNothing); + expect(find.text('320ms'), findsNothing); + + await _doubleTap(tester, find.text('Outbound message')); + await tester.pumpAndSettle(); + + expect(find.text('Message details'), findsOneWidget); + expect(find.text('Direct'), findsNothing); + expect(find.text('320ms'), findsNothing); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('sent channel bubbles open details on double tap', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'sent-channel-signal', + messageType: MessageType.channel, + senderPublicKeyPrefix: _prefix(21), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Broadcast message', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.sent, + echoCount: 2, + lastEchoRssiDbm: -76, + lastEchoSnrRaw: 20, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pump(const Duration(milliseconds: 60)); + + expect(find.text('x2'), findsNothing); + expect(find.text('-76'), findsNothing); + expect(find.text('5.0'), findsNothing); + + await tester.tap(find.text('Broadcast message')); + await tester.pump(kDoubleTapTimeout); + + expect(find.text('x2'), findsNothing); + expect(find.text('-76'), findsNothing); + + await _doubleTap(tester, find.text('Broadcast message')); + await tester.pumpAndSettle(); + + expect(find.text('Message details'), findsOneWidget); + expect(find.text('x2'), findsNothing); + expect(find.text('-76'), findsNothing); + expect(find.text('5.0'), findsNothing); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('sent channel status shows heard count and flood route', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'sent-channel-status', + messageType: MessageType.channel, + senderPublicKeyPrefix: _prefix(22), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000001, + text: 'Flood status', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000001500), + deliveryStatus: MessageDeliveryStatus.sent, + echoCount: 2, + ); + harness.messagesProvider.updateMessageRouteSelection( + 'sent-channel-status', + PathSelection.flood(), + routerFallbackAttempted: false, + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: harness.messagesProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => + Text(message.getLocalizedDeliveryStatus(context)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('2 nodes • Flood route'), findsOneWidget); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('details show sent channel echo relay path', (tester) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'sent-channel-echo-details', + messageType: MessageType.channel, + senderPublicKeyPrefix: _prefix(23), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000002, + text: 'Echo detail path', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000002500), + deliveryStatus: MessageDeliveryStatus.sent, + echoCount: 1, + lastEchoRssiDbm: -88, + lastEchoSnrRaw: 6, + ); + harness.messagesProvider.addSentMessage(message); + harness.messagesProvider.handleMessageEcho( + 'sent-channel-echo-details', + 1, + 6, + -88, + pathBytes: Uint8List.fromList([0x10, 0x20, 0xAA]), + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Echo detail path')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + + expect(find.text('Last echo relay'), findsOneWidget); + expect(find.text('AA'), findsOneWidget); + expect(find.text('Last echo path'), findsOneWidget); + expect(find.text('10:20:aa'), findsWidgets); + expect(find.text('Last echo bytes report'), findsOneWidget); + expect( + find.text('3 bytes [10 20 AA] • hops #1=10, #2=20, #3=AA'), + findsOneWidget, + ); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('channel bubbles refresh to synced channel names', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'channel-name-refresh', + messageType: MessageType.channel, + senderPublicKeyPrefix: _prefix(61), + channelIdx: 3, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Team update', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.sent, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pumpAndSettle(); + + expect(find.text('Channel 3'), findsOneWidget); + expect(find.text('#slovenija'), findsNothing); + + harness.channelsProvider.addOrUpdateChannel( + index: 3, + name: '#slovenija', + secret: Uint8List(16), + ); + await tester.pumpAndSettle(); + + expect(find.text('#slovenija'), findsOneWidget); + expect(find.text('Channel 3'), findsNothing); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('message bubble detects and opens links', (tester) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'message-link', + messageType: MessageType.contact, + senderPublicKeyPrefix: _prefix(31), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Check https://example.com/docs, then ping @[Rescue Team].', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.received, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pumpAndSettle(); + + final richText = tester + .widgetList(find.byType(RichText)) + .firstWhere( + (widget) => + widget.text.toPlainText().contains('https://example.com/docs'), + ); + final linkSpan = _findTextSpan( + richText.text, + (span) => span.text == 'https://example.com/docs', + ); + + expect(richText.text.toPlainText(), contains('https://example.com/docs')); + expect(find.text('@Rescue Team'), findsOneWidget); + + expect(linkSpan, isNotNull); + + final recognizer = linkSpan!.recognizer; + expect(recognizer, isA()); + (recognizer! as TapGestureRecognizer).onTap!(); + await tester.pump(); + + expect(launchedUrls, ['https://example.com/docs']); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('message bubble opens meshcore links in add contact screen', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + const payloadSegment = '00112233445566778899aabbccddeeff'; + final advert = + 'meshcore://' + '$payloadSegment' + '$payloadSegment' + '$payloadSegment' + '$payloadSegment' + '$payloadSegment' + '$payloadSegment' + '0011'; + final message = Message( + id: 'message-meshcore-link', + messageType: MessageType.contact, + senderPublicKeyPrefix: _prefix(41), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Import $advert', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.received, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pumpAndSettle(); + + final richText = tester + .widgetList(find.byType(RichText)) + .firstWhere((widget) => widget.text.toPlainText().contains(advert)); + final linkSpan = _findTextSpan( + richText.text, + (span) => span.text == advert, + ); + + expect(linkSpan, isNotNull); + + final recognizer = linkSpan!.recognizer; + expect(recognizer, isA()); + (recognizer! as TapGestureRecognizer).onTap!(); + await tester.pumpAndSettle(); + + expect(find.text('Import a shared contact advert'), findsOneWidget); + expect(find.text(advert), findsOneWidget); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('message bubble linkifies raw meshcore adverts', (tester) async { + final harness = await _TestHarness.create(); + try { + const advert = + '00112233445566778899aabbccddeeff' + '00112233445566778899aabbccddeeff' + '00112233445566778899aabbccddeeff' + '00112233445566778899aabbccddeeff' + '00112233445566778899aabbccddeeff' + '00112233445566778899aabbccddeeff' + '0011'; + final message = Message( + id: 'message-meshcore-raw', + messageType: MessageType.contact, + senderPublicKeyPrefix: _prefix(51), + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Import raw advert: $advert', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.received, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pumpAndSettle(); + + final richText = tester + .widgetList(find.byType(RichText)) + .firstWhere((widget) => widget.text.toPlainText().contains(advert)); + final linkSpan = _findTextSpan( + richText.text, + (span) => span.text == advert, + ); + + expect(linkSpan, isNotNull); + + final recognizer = linkSpan!.recognizer; + expect(recognizer, isA()); + (recognizer! as TapGestureRecognizer).onTap!(); + await tester.pumpAndSettle(); + + expect(find.text('Import a shared contact advert'), findsOneWidget); + expect(find.text(advert), findsOneWidget); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets('technical details show a location map for stored snapshots', ( + tester, + ) async { + final harness = await _TestHarness.create(); + try { + final message = Message( + id: 'message-location-details', + messageType: MessageType.contact, + senderPublicKeyPrefix: _prefix(71), + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Location details', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.received, + ); + + harness.messagesProvider.addMessage( + message, + contactLocationSnapshot: MessageContactLocation( + location: const LatLng(46.0569, 14.5058), + source: 'advert', + capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600), + sourceTimestamp: DateTime.fromMillisecondsSinceEpoch(1700000000400), + ), + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Location details')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + + expect(find.byType(flutter_map.FlutterMap), findsOneWidget); + expect(find.text('46.056900, 14.505800'), findsOneWidget); + expect(find.text('shared advert'), findsOneWidget); + } finally { + await _disposeHarness(tester, harness); + } + }); + + testWidgets( + 'technical details show a location map for sent channel messages using live position fallback', + (tester) async { + final harness = await _TestHarness.create(); + try { + LocationTrackingService().currentPosition = Position( + latitude: 46.0569, + longitude: 14.5058, + timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000), + accuracy: 6.0, + altitude: 300.0, + altitudeAccuracy: 6.0, + heading: 0.0, + headingAccuracy: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + ); + + final message = Message( + id: 'channel-live-fallback', + messageType: MessageType.channel, + senderPublicKeyPrefix: _prefix(81), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: 'Channel fallback', + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + deliveryStatus: MessageDeliveryStatus.sent, + ); + + await tester.pumpWidget(_buildApp(harness, message)); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Channel fallback')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + + expect(find.byType(flutter_map.FlutterMap), findsOneWidget); + expect(find.text('46.056900, 14.505800'), findsOneWidget); + expect(find.text('shared location'), findsOneWidget); + } finally { + await _disposeHarness(tester, harness); + } + }, + ); +} + +Widget _buildApp(_TestHarness harness, Message message) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: harness.connectionProvider), + ChangeNotifierProvider.value(value: harness.contactsProvider), + ChangeNotifierProvider.value(value: harness.messagesProvider), + ChangeNotifierProvider.value(value: harness.drawingProvider), + ChangeNotifierProvider.value(value: harness.channelsProvider), + ChangeNotifierProvider.value(value: harness.voiceProvider), + ChangeNotifierProvider.value(value: harness.imageProvider), + ChangeNotifierProvider.value(value: harness.appProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: MessageBubble(message: message)), + ), + ); +} + +class _TestHarness { + final connectionProvider = ConnectionProvider(); + final contactsProvider = ContactsProvider(); + final messagesProvider = MessagesProvider(); + final drawingProvider = DrawingProvider(); + final channelsProvider = ChannelsProvider()..initializePublicChannel(); + final voiceProvider = VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ); + final imageProvider = ip.ImageProvider(); + + late final AppProvider appProvider; + + static Future<_TestHarness> create() async { + final harness = _TestHarness(); + await harness.messagesProvider.initialize(); + await harness.drawingProvider.initialize(); + harness.appProvider = AppProvider( + connectionProvider: harness.connectionProvider, + contactsProvider: harness.contactsProvider, + messagesProvider: harness.messagesProvider, + drawingProvider: harness.drawingProvider, + channelsProvider: harness.channelsProvider, + voiceProvider: harness.voiceProvider, + imageProvider: harness.imageProvider, + ); + return harness; + } + + bool _isDisposed = false; + + void dispose() { + if (_isDisposed) { + return; + } + _isDisposed = true; + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + } +} + +Uint8List _prefix(int seed) => + Uint8List.fromList(List.generate(6, (index) => seed + index)); + +Uint8List _key(int seed) => + Uint8List.fromList(List.generate(32, (index) => seed + index)); + +Future _disposeHarness(WidgetTester tester, _TestHarness harness) async { + await tester.pump(kDoubleTapTimeout); + await tester.pumpWidget(const SizedBox.shrink()); + harness.dispose(); + await tester.pump(); +} + +Future _doubleTap(WidgetTester tester, Finder finder) async { + await tester.tap(finder); + await tester.pump(kDoubleTapMinTime); + await tester.tap(finder); + await tester.pump(); +} + +TextSpan? _findTextSpan( + InlineSpan span, + bool Function(TextSpan span) predicate, +) { + if (span is! TextSpan) { + return null; + } + if (predicate(span)) { + return span; + } + for (final child in span.children ?? const []) { + final match = _findTextSpan(child, predicate); + if (match != null) { + return match; + } + } + return null; +} diff --git a/test/widgets/recipient_selector_sheet_test.dart b/test/widgets/recipient_selector_sheet_test.dart new file mode 100644 index 0000000..b5bc193 --- /dev/null +++ b/test/widgets/recipient_selector_sheet_test.dart @@ -0,0 +1,246 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/widgets/messages/recipient_selector_sheet.dart'; + +void main() { + Contact buildContact({ + required String name, + required ContactType type, + int secondByte = 0, + int flags = 0, + int lastAdvert = 0, + }) { + final publicKey = Uint8List(32); + publicKey[1] = secondByte; + + return Contact( + publicKey: publicKey, + type: type, + flags: flags, + outPathLen: 0, + outPath: Uint8List(0), + advName: name, + lastAdvert: lastAdvert, + advLat: 0, + advLon: 0, + lastMod: 0, + ); + } + + Future pumpSheet( + WidgetTester tester, { + List? contacts, + List? channels, + MessagesProvider? messagesProvider, + bool showAllOption = true, + }) async { + final resolvedChannels = + channels ?? + [buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3)]; + final resolvedContacts = + contacts ?? [buildContact(name: 'John Smith', type: ContactType.chat)]; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: RecipientSelectorSheet( + contacts: resolvedContacts, + rooms: const [], + channels: resolvedChannels, + unreadCount: 11, + unreadCountsByPublicKey: { + for (final channel in resolvedChannels) channel.publicKeyHex: 7, + for (final contact in resolvedContacts) contact.publicKeyHex: 3, + }, + currentDestinationType: 'all', + showAllOption: showAllOption, + messagesProvider: messagesProvider, + onSelect: (selectedContact, destinationType) {}, + ), + ), + ), + ); + } + + testWidgets('renders unread badges for all and destination filters', ( + tester, + ) async { + await pumpSheet(tester); + + expect(find.byKey(const Key('unread-badge-11')), findsOneWidget); + expect(find.byKey(const Key('unread-badge-7')), findsOneWidget); + expect(find.byKey(const Key('unread-badge-3')), findsOneWidget); + expect(find.text('11'), findsOneWidget); + expect(find.text('7'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + }); + + testWidgets('can hide show all option for contact-only flows', ( + tester, + ) async { + final contact = buildContact(name: 'John Smith', type: ContactType.chat); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: RecipientSelectorSheet( + contacts: [contact], + rooms: const [], + channels: const [], + unreadCount: 0, + unreadCountsByPublicKey: const {}, + showAllOption: false, + onSelect: (selectedRecipient, draftMessage) {}, + ), + ), + ), + ); + + expect(find.text('Show all'), findsNothing); + expect(find.text('John Smith'), findsOneWidget); + }); + + testWidgets('sorts contacts with favourites first, then last seen', ( + tester, + ) async { + final recentNonFavourite = buildContact( + name: 'Alpha', + type: ContactType.chat, + secondByte: 1, + lastAdvert: 300, + ); + final olderFavourite = buildContact( + name: 'Bravo', + type: ContactType.chat, + secondByte: 2, + flags: 0x01, + lastAdvert: 100, + ); + final newerFavourite = buildContact( + name: 'Charlie', + type: ContactType.chat, + secondByte: 3, + flags: 0x01, + lastAdvert: 200, + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: RecipientSelectorSheet( + contacts: [recentNonFavourite, olderFavourite, newerFavourite], + rooms: const [], + channels: const [], + unreadCount: 0, + unreadCountsByPublicKey: const {}, + showAllOption: false, + onSelect: (selectedRecipient, draftMessage) {}, + ), + ), + ), + ); + + final charlieY = tester.getTopLeft(find.text('Charlie')).dy; + final bravoY = tester.getTopLeft(find.text('Bravo')).dy; + final alphaY = tester.getTopLeft(find.text('Alpha')).dy; + + expect(charlieY, lessThan(bravoY)); + expect(bravoY, lessThan(alphaY)); + }); + + testWidgets('shows channel activity and participants instead of raw ids', ( + tester, + ) async { + final channel = buildContact( + name: 'Ops', + type: ContactType.channel, + secondByte: 3, + ); + final messagesProvider = MessagesProvider() + ..addMessage( + Message( + id: 'channel-activity', + messageType: MessageType.channel, + senderName: 'Radio Alpha', + channelIdx: 3, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: + DateTime.now() + .subtract(const Duration(minutes: 5)) + .millisecondsSinceEpoch ~/ + 1000, + text: 'status update', + receivedAt: DateTime.now().subtract(const Duration(minutes: 5)), + ), + ); + + await pumpSheet( + tester, + contacts: const [], + channels: [channel], + messagesProvider: messagesProvider, + showAllOption: false, + ); + + expect( + find.byKey(Key('channel-participants-${channel.publicKeyHex}')), + findsOneWidget, + ); + expect(find.text('Radio Alpha'), findsNothing); + expect(find.text('5m ago'), findsOneWidget); + expect(find.textContaining('Channel 3'), findsNothing); + expect(find.text(channel.publicKeyShort.toUpperCase()), findsNothing); + }); + + testWidgets('shows contact activity instead of the public key', ( + tester, + ) async { + final contact = buildContact( + name: 'John Smith', + type: ContactType.chat, + secondByte: 4, + ); + final messagesProvider = MessagesProvider() + ..addMessage( + Message( + id: 'contact-activity', + messageType: MessageType.contact, + recipientPublicKey: contact.publicKey, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: + DateTime.now() + .subtract(const Duration(hours: 2)) + .millisecondsSinceEpoch ~/ + 1000, + text: 'check-in', + receivedAt: DateTime.now().subtract(const Duration(hours: 2)), + ), + ); + + await pumpSheet( + tester, + contacts: [contact], + channels: const [], + messagesProvider: messagesProvider, + showAllOption: false, + ); + + expect(find.text('John Smith'), findsOneWidget); + expect(find.text('2h ago'), findsOneWidget); + expect(find.text(contact.publicKeyShort), findsNothing); + }); +} diff --git a/test/widgets/sensor_auto_refresh_options_test.dart b/test/widgets/sensor_auto_refresh_options_test.dart new file mode 100644 index 0000000..4183f8b --- /dev/null +++ b/test/widgets/sensor_auto_refresh_options_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/screens/sensors_tab.dart'; + +void main() { + testWidgets('renders expanded auto refresh intervals and selects value', ( + tester, + ) async { + var selected = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SensorAutoRefreshOptions( + selectedMinutes: selected, + onSelected: (minutes) { + selected = minutes; + }, + ), + ), + ), + ); + + expect(find.text('Off'), findsOneWidget); + expect(find.text('5m'), findsOneWidget); + expect(find.text('15m'), findsOneWidget); + expect(find.text('30m'), findsOneWidget); + expect(find.text('60m'), findsOneWidget); + expect(find.text('6h'), findsOneWidget); + expect(find.text('12h'), findsOneWidget); + expect(find.text('24h'), findsOneWidget); + + await tester.tap(find.text('24h')); + await tester.pump(); + + expect(selected, 1440); + }); +} diff --git a/test/widgets/sensor_history_sheet_test.dart b/test/widgets/sensor_history_sheet_test.dart new file mode 100644 index 0000000..3715d61 --- /dev/null +++ b/test/widgets/sensor_history_sheet_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:meshcore_sar_app/widgets/sensors/sensor_history_sheet.dart'; + +void main() { + test('history screen honors initial field key when available', () { + final selectedFieldKey = resolveInitialSensorHistoryField( + requestedFieldKey: 'extra:illuminance_2', + availableFieldKeys: const [ + 'temperature', + 'extra:illuminance_2', + ], + ); + + expect(selectedFieldKey, 'extra:illuminance_2'); + }); + + test('history screen falls back to first available field', () { + final selectedFieldKey = resolveInitialSensorHistoryField( + requestedFieldKey: 'extra:missing', + availableFieldKeys: const [ + 'temperature', + 'extra:illuminance_2', + ], + ); + + expect(selectedFieldKey, 'temperature'); + }); + + test('history screen returns null when no fields are available', () { + final selectedFieldKey = resolveInitialSensorHistoryField( + requestedFieldKey: 'temperature', + availableFieldKeys: const [], + ); + + expect(selectedFieldKey, isNull); + }); + + test('history range filters to the latest 24 hours', () { + final samples = [ + SensorHistorySample( + timestamp: DateTime(2026, 4, 1, 8), + values: const {'temperature': 10}, + ), + SensorHistorySample( + timestamp: DateTime(2026, 4, 2, 7), + values: const {'temperature': 11}, + ), + SensorHistorySample( + timestamp: DateTime(2026, 4, 2, 8), + values: const {'temperature': 12}, + ), + ]; + + final filtered = filterSensorHistorySamples( + samples: samples, + range: SensorHistoryRange.day, + ); + + expect(filtered.map((sample) => sample.values['temperature']), [10, 11, 12]); + }); + + test('history range all keeps every sample', () { + final samples = [ + SensorHistorySample( + timestamp: DateTime(2026, 4, 1, 8), + values: const {'temperature': 10}, + ), + SensorHistorySample( + timestamp: DateTime(2026, 4, 2, 8), + values: const {'temperature': 12}, + ), + ]; + + final filtered = filterSensorHistorySamples( + samples: samples, + range: SensorHistoryRange.all, + ); + + expect(filtered, hasLength(2)); + }); +} diff --git a/test/widgets/sensor_metric_selector_item_test.dart b/test/widgets/sensor_metric_selector_item_test.dart new file mode 100644 index 0000000..8e2fe77 --- /dev/null +++ b/test/widgets/sensor_metric_selector_item_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/screens/sensors_tab.dart'; +import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; + +void main() { + testWidgets('renders selector previews and channel badges', (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorMetricSelectorItem( + option: const SensorMetricOption( + key: 'extra:illuminance_2', + label: 'Illuminance (ch 2)', + defaultLabel: 'Illuminance', + channel: 2, + valuePreview: '~4.2 W/m2', + previewCardData: SensorMetricCardData( + fieldKey: 'extra:illuminance_2', + icon: Icons.light_mode_outlined, + label: 'Illuminance', + value: '~4.2 W/m2', + accent: Color(0xFFC17B1D), + channel: 2, + ), + ), + visible: true, + span: 1, + canMoveUp: true, + canMoveDown: true, + onToggle: (_) {}, + onRename: () {}, + onMoveUp: () {}, + onMoveDown: () {}, + onSpanChanged: (_) {}, + ), + ), + ), + ); + + expect(find.text('Show on sensor card'), findsOneWidget); + expect(find.text('~4.2 W/m2'), findsOneWidget); + expect(find.textContaining('lx'), findsNothing); + expect( + find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')), + findsOneWidget, + ); + expect(find.text('Channel 2'), findsOneWidget); + }); + + testWidgets('omits duplicate channel chip for channel 1', (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorMetricSelectorItem( + option: const SensorMetricOption( + key: 'battery', + label: 'Battery', + defaultLabel: 'Battery', + channel: 1, + valuePreview: '84%', + previewCardData: SensorMetricCardData( + fieldKey: 'battery', + icon: Icons.battery_5_bar, + label: 'Battery', + value: '84%', + accent: Color(0xFF4B8E2F), + channel: 1, + ), + ), + visible: true, + span: 1, + canMoveUp: true, + canMoveDown: true, + onToggle: (_) {}, + onRename: () {}, + onMoveUp: () {}, + onMoveDown: () {}, + onSpanChanged: (_) {}, + ), + ), + ), + ); + + expect(find.text('Rename'), findsOneWidget); + expect( + find.byKey(const ValueKey('sensor_selector_channel_battery')), + findsNothing, + ); + expect(find.text('Channel 1'), findsNothing); + }); +} diff --git a/test/widgets/sensor_telemetry_card_test.dart b/test/widgets/sensor_telemetry_card_test.dart new file mode 100644 index 0000000..eb58a30 --- /dev/null +++ b/test/widgets/sensor_telemetry_card_test.dart @@ -0,0 +1,622 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; +import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; + +void main() { + Contact buildContact() { + final publicKey = Uint8List(32); + publicKey[0] = 0x44; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + temperature: 21.5, + extraSensorData: const { + '__source_channel:temperature': 1, + 'illuminance_2': 500.0, + }, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + } + + testWidgets('renders custom labels and channel badges', (tester) async { + final contact = buildContact(); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'temperature', 'extra:illuminance_2'}, + labelOverrides: const { + 'temperature': 'Ambient', + 'extra:illuminance_2': 'Light', + }, + fieldSpans: sensorFullWidthFieldSpans(const { + 'temperature', + 'extra:illuminance_2', + }), + ), + ), + ), + ); + + expect(find.text('Ambient'), findsOneWidget); + expect(find.text('Light'), findsOneWidget); + expect(find.text('Temperature'), findsNothing); + expect(find.text('Illuminance'), findsNothing); + expect(find.text('~4.2 W/m2'), findsOneWidget); + expect(find.textContaining('lx'), findsNothing); + expect( + find.byKey(const ValueKey('sensor_metric_temperature')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')), + findsOneWidget, + ); + }); + + testWidgets('renders metrics in the provided order', (tester) async { + final publicKey = Uint8List(32); + publicKey[0] = 0x45; + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + batteryPercentage: 84, + temperature: 21.5, + extraSensorData: const { + '__source_channel:battery': 1, + '__source_channel:temperature': 1, + 'illuminance_2': 500.0, + }, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const { + 'battery', + 'temperature', + 'extra:illuminance_2', + }, + fieldOrder: const ['extra:illuminance_2', 'temperature', 'battery'], + fieldSpans: sensorFullWidthFieldSpans(const { + 'battery', + 'temperature', + 'extra:illuminance_2', + }), + ), + ), + ), + ); + + final illuminanceTop = tester.getTopLeft( + find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')), + ); + final temperatureTop = tester.getTopLeft( + find.byKey(const ValueKey('sensor_metric_temperature')), + ); + final batteryTop = tester.getTopLeft( + find.byKey(const ValueKey('sensor_metric_battery')), + ); + + expect(illuminanceTop.dy, lessThan(temperatureTop.dy)); + expect(temperatureTop.dy, lessThan(batteryTop.dy)); + }); + + testWidgets('renders MeshCore custom weather metrics', (tester) async { + tester.platformDispatcher.localeTestValue = const Locale('sl', 'SI'); + addTearDown(tester.platformDispatcher.clearLocaleTestValue); + final publicKey = Uint8List(32); + publicKey[0] = 0x46; + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + extraSensorData: const {'gust_2': 3.7, 'dew_2': 2.0, 'rain_2': 12.3}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const { + 'extra:gust_2', + 'extra:dew_2', + 'extra:rain_2', + }, + fieldSpans: sensorFullWidthFieldSpans(const { + 'extra:gust_2', + 'extra:dew_2', + 'extra:rain_2', + }), + ), + ), + ), + ); + + expect(find.text('Wind gust'), findsOneWidget); + expect(find.text('Dew point'), findsOneWidget); + expect(find.text('Rain'), findsOneWidget); + expect(find.textContaining('km/h'), findsOneWidget); + expect(find.textContaining('m/s'), findsNothing); + expect(find.text('2°C'), findsOneWidget); + expect(find.text('12.3 mm'), findsOneWidget); + }); + + testWidgets('renders speed in mph for imperial system locale', ( + tester, + ) async { + tester.platformDispatcher.localeTestValue = const Locale('en', 'US'); + addTearDown(tester.platformDispatcher.clearLocaleTestValue); + + final publicKey = Uint8List(32); + publicKey[0] = 0x4A; + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + extraSensorData: const {'speed_2': 3.7}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'extra:speed_2'}, + fieldSpans: sensorFullWidthFieldSpans(const {'extra:speed_2'}), + ), + ), + ), + ); + + expect(find.textContaining('mph'), findsOneWidget); + expect(find.textContaining('m/s'), findsNothing); + }); + + testWidgets('gps preview map recenters when telemetry location changes', ( + tester, + ) async { + final publicKey = Uint8List(32); + publicKey[0] = 0x4B; + + Contact buildGpsContact(double latitude, double longitude) => Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'GPS Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + gpsLocation: LatLng(latitude, longitude), + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + + var contact = buildGpsContact(46.0569, 14.5058); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () { + setState(() { + contact = buildGpsContact(46.1000, 14.6000); + }); + }, + child: const Text('Update'), + ), + SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'gps'}, + fieldSpans: sensorFullWidthFieldSpans(const {'gps'}), + ), + ], + ), + ), + ); + }, + ), + ); + + final initialMap = tester.widget( + find.byType(flutter_map.FlutterMap).first, + ); + final initialCenter = initialMap.mapController!.camera.center; + expect(initialCenter.latitude, closeTo(46.0569, 0.0001)); + expect(initialCenter.longitude, closeTo(14.5058, 0.0001)); + + await tester.tap(find.text('Update')); + await tester.pump(); + + final updatedMap = tester.widget( + find.byType(flutter_map.FlutterMap).first, + ); + final updatedCenter = updatedMap.mapController!.camera.center; + expect(updatedCenter.latitude, closeTo(46.1000, 0.0001)); + expect(updatedCenter.longitude, closeTo(14.6000, 0.0001)); + }); + + testWidgets('renders generic percentage separately from UV for weather payload', (tester) async { + tester.view.physicalSize = const Size(1600, 2600); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final publicKey = Uint8List(32); + publicKey[0] = 0x49; + final payload = Uint8List.fromList([ + 0x01, 0x74, 0x00, 0x00, + 0x02, 0x78, 0x64, + 0x02, 0x73, 0x24, 0x31, + 0x02, 0x65, 0x67, 0xDE, + 0x02, 0x8A, 0xFF, 0xE6, + 0x02, 0x74, 0x01, 0xE0, + 0x02, 0x9D, 0x00, + 0x02, 0x68, 0x76, + 0x02, 0x81, 0x00, 0xDC, + 0x02, 0x89, 0x01, 0x36, + 0x02, 0x67, 0x00, 0x2F, + 0x02, 0xAD, 0x0A, + 0x02, 0x84, 0x00, 0x50, + 0x02, 0x8B, 0x00, 0xB3, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]); + + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: CayenneLppParser.parse(payload), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const { + 'temperature', + 'humidity', + 'pressure', + 'extra:percentage_2', + 'extra:dew_2', + 'extra:speed_2', + 'extra:gust_2', + 'extra:uv_2', + 'extra:direction_2', + 'extra:rain_2', + }, + fieldSpans: sensorFullWidthFieldSpans(const { + 'temperature', + 'humidity', + 'pressure', + 'extra:percentage_2', + 'extra:dew_2', + 'extra:speed_2', + 'extra:gust_2', + 'extra:uv_2', + 'extra:direction_2', + 'extra:rain_2', + }), + ), + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('sensor_metric_extra:percentage_2')), findsOneWidget); + expect(find.byKey(const ValueKey('sensor_metric_extra:uv_2')), findsOneWidget); + expect(find.text('100%'), findsOneWidget); + expect(find.text('1%'), findsNothing); + expect(find.text('1'), findsOneWidget); + expect(find.text('80°'), findsOneWidget); + expect(find.text('8°'), findsNothing); + }); + + testWidgets('renders direction metric with compact compass layout', ( + tester, + ) async { + final publicKey = Uint8List(32); + publicKey[0] = 0x47; + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + extraSensorData: const {'direction_2': 111.0}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'extra:direction_2'}, + fieldSpans: sensorFullWidthFieldSpans(const {'extra:direction_2'}), + ), + ), + ), + ); + + expect(find.text('Direction'), findsOneWidget); + expect(find.text('111°'), findsOneWidget); + expect(find.text('E'), findsOneWidget); + expect(find.byIcon(Icons.navigation_rounded), findsOneWidget); + }); + + testWidgets('long pressing a telemetry bubble triggers refresh', ( + tester, + ) async { + final contact = buildContact(); + var refreshCount = 0; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'temperature'}, + fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}), + onRefresh: () async { + refreshCount += 1; + }, + ), + ), + ), + ); + + await tester.longPress( + find.byKey(const ValueKey('sensor_metric_temperature')), + ); + await tester.pumpAndSettle(); + + expect(refreshCount, 1); + }); + + testWidgets('overflow menu exposes move actions', (tester) async { + final contact = buildContact(); + var moveUpCount = 0; + var moveDownCount = 0; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'temperature'}, + fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}), + onMoveUp: () async { + moveUpCount += 1; + }, + onMoveDown: () async { + moveDownCount += 1; + }, + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + expect(find.text('Move up'), findsOneWidget); + expect(find.text('Move down'), findsOneWidget); + + await tester.tap(find.text('Move down')); + await tester.pumpAndSettle(); + + expect(moveUpCount, 0); + expect(moveDownCount, 1); + }); + + testWidgets('tapping a measurement tile triggers metric callback', ( + tester, + ) async { + final contact = buildContact(); + String? tappedFieldKey; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'temperature'}, + fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}), + onMetricTap: (fieldKey) async { + tappedFieldKey = fieldKey; + }, + ), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('sensor_metric_temperature'))); + await tester.pumpAndSettle(); + + expect(tappedFieldKey, 'temperature'); + }); + + testWidgets('tapping the card body does not trigger metric callback', ( + tester, + ) async { + final contact = buildContact(); + var tapCount = 0; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'temperature'}, + fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}), + onMetricTap: (_) async { + tapCount += 1; + }, + ), + ), + ), + ); + + await tester.tap(find.text('WX Station')); + await tester.pumpAndSettle(); + + expect(tapCount, 0); + }); + + testWidgets('raw response metadata alone does not show an overflow menu', ( + tester, + ) async { + final publicKey = Uint8List(32); + publicKey[0] = 0x48; + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + temperature: 21.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + extraSensorData: const {'__raw_lpp_hex': '01 67 00 d7'}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'temperature'}, + fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}), + ), + ), + ), + ); + + expect(find.byIcon(Icons.more_vert), findsNothing); + expect(find.byIcon(Icons.chevron_right_rounded), findsNothing); + }); +} diff --git a/test/widgets/traffic_stats_reporting_section_test.dart b/test/widgets/traffic_stats_reporting_section_test.dart new file mode 100644 index 0000000..9f939c9 --- /dev/null +++ b/test/widgets/traffic_stats_reporting_section_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/services/traffic_stats_reporting_service.dart'; +import 'package:meshcore_sar_app/widgets/settings/traffic_stats_reporting_section.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final launchedUrls = []; + + setUp(() { + launchedUrls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + (call) async { + switch (call.method) { + case 'canLaunch': + return true; + case 'launch': + final arguments = Map.from( + call.arguments as Map, + ); + launchedUrls.add(arguments['url'] as String); + return true; + } + return null; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + null, + ); + }); + + testWidgets('starts enabled by default and can be disabled', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final service = TrafficStatsReportingService( + client: MockClient((request) async => http.Response('{}', 200)), + now: () => DateTime.utc(2026, 4, 3, 10, 6), + appVersionProvider: () async => '2026.0402.1+44', + ); + await service.initialize( + deviceKey6Provider: () => 'a1b2c3d4e5f6', + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ListenableBuilder( + listenable: service, + builder: (context, child) => TrafficStatsReportingSection( + service: service, + ), + ), + ), + ), + ); + + expect(find.text('Anonymous RX stats'), findsOneWidget); + expect(find.text('Upload packet totals every 5 min'), findsOneWidget); + expect(find.text('Reporting interval'), findsNothing); + expect(service.isEnabled, isTrue); + + await tester.tap(find.widgetWithText(TextButton, 'View')); + await tester.pump(); + + expect(launchedUrls, ['https://mcstats.dz0ny.dev']); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect(service.isEnabled, isFalse); + expect(service.intervalMinutes, 5); + + service.dispose(); + }); +} diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart new file mode 100644 index 0000000..65e97d1 --- /dev/null +++ b/test_driver/integration_test.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver_extended.dart'; + +Future main() async { + final driver = await FlutterDriver.connect(); + await integrationDriver( + driver: driver, + onScreenshot: (name, image, [args]) async { + final outputDir = Platform.environment['SCREENSHOT_OUTPUT_DIR'] ?? + 'ios/fastlane/screenshots/en-US'; + final file = File('$outputDir/$name.png'); + await file.parent.create(recursive: true); + await file.writeAsBytes(image); + return true; + }, + ); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..6b278d4 Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..07c3067 Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..097d07a Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..07c3067 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..097d07a Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b6f9308 --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + MeshCore SAR + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..90edce8 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "MeshCore SAR", + "short_name": "MeshCore SAR", + "start_url": ".", + "display": "standalone", + "background_color": "#ddd", + "theme_color": "#fff", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..e980762 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(meshcore_sar_app LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "meshcore_sar_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..fbe3b11 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,44 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterAvifWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterAvifWindowsPlugin")); + FlutterBluePlusPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); + FlutterLibserialportPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterLibserialportPlugin")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + NsdWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("NsdWindowsPluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..0ed801f --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,35 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows + file_selector_windows + flutter_avif_windows + flutter_blue_plus_winrt + flutter_libserialport + geolocator_windows + nsd_windows + permission_handler_windows + record_windows + share_plus + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..e86239c --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.meshcore.sar" "\0" + VALUE "FileDescription", "meshcore_sar_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "meshcore_sar_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.meshcore.sar. All rights reserved." "\0" + VALUE "OriginalFilename", "meshcore_sar_app.exe" "\0" + VALUE "ProductName", "meshcore_sar_app" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..34914b8 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"meshcore_sar_app", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..a2a1c74 Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/worker/README.md b/worker/README.md new file mode 100644 index 0000000..91d7c87 --- /dev/null +++ b/worker/README.md @@ -0,0 +1,29 @@ +# MeshCore SAR Stats Worker + +This worker follows the same split as `/Users/dz0ny/site-vendorvigilance`: + +- Astro builds the dashboard UI into `dist` +- a small Cloudflare Worker handles `/api/*` before assets are served + +## Layout + +- `src/pages/index.astro` - dashboard shell +- `worker/index.ts` - Cloudflare Worker entrypoint +- `worker/stats.ts` - D1 queries, payload validation, and aggregation helpers +- `public/.assetsignore` - keeps Astro's private `_worker.js` bundle out of public asset uploads +- `schema.sql` - D1 schema + +## Setup + +1. Install dependencies with `bun install`. +2. Create a D1 database with `bunx wrangler d1 create meshcore_sar_rx_stats`. +3. Apply the schema with `bunx wrangler d1 execute meshcore_sar_rx_stats --remote --file=./schema.sql`. +4. Add the real D1 binding id to `wrangler.toml` when deploying. +5. Build the dashboard with `bun run build`. +6. Run checks with `bun run check`, `bun run test`, and `bun run typecheck`. + +## Routes + +- `GET /` - static Astro dashboard +- `GET /api/dashboard?window=24h|7d|30d` - aggregated dashboard JSON +- `POST /api/ingest` - anonymous RX stats ingest diff --git a/worker/astro.config.mjs b/worker/astro.config.mjs new file mode 100644 index 0000000..bb309ef --- /dev/null +++ b/worker/astro.config.mjs @@ -0,0 +1,30 @@ +import { defineConfig } from "astro/config"; +import cloudflare from "@astrojs/cloudflare"; +import react from "@astrojs/react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + imageService: "compile", + integrations: [react()], + vite: { + cacheDir: ".astro/vite", + plugins: [tailwindcss()], + resolve: { + alias: { + "@": "/src", + }, + }, + }, + build: { + concurrency: 4, + }, + server: { + port: 4321, + host: "0.0.0.0", + allowedHosts: true, + }, + devToolbar: { + enabled: false, + }, + adapter: cloudflare(), +}); diff --git a/worker/bun.lock b/worker/bun.lock new file mode 100644 index 0000000..fc1ccbe --- /dev/null +++ b/worker/bun.lock @@ -0,0 +1,1291 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "meshcore-sar-stats", + "dependencies": { + "@astrojs/check": "^0.9.6", + "@astrojs/cloudflare": "^12.6.12", + "@astrojs/react": "^4.4.2", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@tailwindcss/vite": "^4.1.18", + "astro": "^5.17.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260403.1", + "@types/bun": "^1.3.11", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "typescript": "^5.9.3", + "wrangler": "^4.80.0", + }, + }, + }, + "packages": { + "@astrojs/check": ["@astrojs/check@0.9.8", "", { "dependencies": { "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw=="], + + "@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.13", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20260116.0", "tinyglobby": "^0.2.15", "vite": "^6.4.1", "wrangler": "4.59.2" }, "peerDependencies": { "astro": "^5.7.0" } }, "sha512-oKaCyiovyQr183r9U93787Ju1zwk+rRMgPnLTwCLckHmOUK7sltA1Gp4LSGt8oNMgqQS6jR7uRdfQ/NPul37QA=="], + + "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], + + "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], + + "@astrojs/language-server": ["@astrojs/language-server@2.16.6", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.3", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug=="], + + "@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], + + "@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], + + "@astrojs/react": ["@astrojs/react@4.4.2", "", { "dependencies": { "@vitejs/plugin-react": "^4.7.0", "ultrahtml": "^1.6.0", "vite": "^6.4.1" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-1tl95bpGfuaDMDn8O3x/5Dxii1HPvzjvpL2YTuqOOrQehs60I2DKiDgh1jrKc7G8lv+LQT5H15V6QONQ+9waeQ=="], + + "@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="], + + "@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@1.0.0", "", {}, "sha512-qZxHwVnmb5FXuvRsaIGaqWgnftjCuMY+GSbaVZdBmE4j8AfgPqKPxYp8SUERyJcjpKCEmO4wD6ybuGH8A2kVRQ=="], + + "@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.3", "", { "dependencies": { "yaml": "^2.8.2" } }, "sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="], + + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260401.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZSmceM70jH6k+/62VkEcmMNzrpr4kSctkX5Lsgqv38KktfhPY/hsh75y1lRoPWS3H3kgMa4p2pUSlidZR1u2hw=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260401.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7UKWF+IUZ3NXMVPsDg8Cjg0r58b+uYlfvs5Yt8bvtU+geCtW4P2MxRHmRSEo8SryckXOJjb/b8tcncgCykFu8g=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260401.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MDWUH/0bvL/l9aauN8zEddyYOXId1OueqrUCXXENNJ95R/lSmF6OgGVuXaYhoIhxQkNiEJ/0NOlnVYj9mJq4dw=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260401.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-UgkzpMzVWM/bwbo3vjCTg2aoKfGcUhiEoQoDdo6RGWvbHRJyLVZ4VQCG9ZcISiztkiS2ICCoYOtPy6M/lV6Gcw=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260401.1", "", { "os": "win32", "cpu": "x64" }, "sha512-HBLzcQF5iF4Qv20tQ++pG7xs3OsCnaIbc+GAi6fmhUKZhvmzvml/jwrQzLJ+MPm0cQo41K5OO/U3T4S8tvJetQ=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260403.1", "", {}, "sha512-p6oSNt3yUwcxSoXZ7qCog7+kgQbkSx1Beoci7TMb/xbRF05VR0cO/p1XUE2SLHZ7IgSIc3tNMpFKa0L0fa3Lzg=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], + + "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], + + "@emmetio/css-parser": ["@emmetio/css-parser@0.4.1", "", { "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" } }, "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ=="], + + "@emmetio/html-matcher": ["@emmetio/html-matcher@1.3.0", "", { "dependencies": { "@emmetio/scanner": "^1.0.0" } }, "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ=="], + + "@emmetio/scanner": ["@emmetio/scanner@1.0.4", "", {}, "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA=="], + + "@emmetio/stream-reader": ["@emmetio/stream-reader@2.2.0", "", {}, "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw=="], + + "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + + "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], + + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/language-server": ["@volar/language-server@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw=="], + + "@volar/language-service": ["@volar/language-service@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vscode/emmet-helper": ["@vscode/emmet-helper@2.11.0", "", { "dependencies": { "emmet": "^2.4.3", "jsonc-parser": "^2.3.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.15.1", "vscode-uri": "^3.0.8" } }, "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw=="], + + "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], + + "astro": ["astro@5.18.1", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.6", "@astrojs/markdown-remark": "6.3.11", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001784", "", {}, "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "defu": ["defu@6.1.6", "", {}, "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], + + "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.331", "", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="], + + "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], + + "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], + + "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "miniflare": ["miniflare@4.20260401.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260401.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-lngHPzZFN9sxYG/mhzvnWiBMNVAN5MsO/7g32ttJ07rymtiK/ZBalODTKb8Od+BQdlU5DOR4CjVt9NydjnUyYg=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], + + "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], + + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], + + "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], + + "p-queue": ["p-queue@8.1.1", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^6.1.2" } }, "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ=="], + + "p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], + + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + + "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="], + + "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-smartypants": ["remark-smartypants@3.0.2", "", { "dependencies": { "retext": "^9.0.0", "retext-smartypants": "^6.0.0", "unified": "^11.0.4", "unist-util-visit": "^5.0.0" } }, "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], + + "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="], + + "retext-smartypants": ["retext-smartypants@6.2.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ=="], + + "retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="], + + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], + + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="], + + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + + "ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="], + + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + + "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-modify-children": ["unist-util-modify-children@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "array-iterate": "^2.0.0" } }, "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-children": ["unist-util-visit-children@3.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "volar-service-css": ["volar-service-css@0.0.70", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw=="], + + "volar-service-emmet": ["volar-service-emmet@0.0.70", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg=="], + + "volar-service-html": ["volar-service-html@0.0.70", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ=="], + + "volar-service-prettier": ["volar-service-prettier@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg=="], + + "volar-service-typescript": ["volar-service-typescript@0.0.70", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg=="], + + "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ=="], + + "volar-service-yaml": ["volar-service-yaml@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.20.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ=="], + + "vscode-css-languageservice": ["vscode-css-languageservice@6.3.10", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA=="], + + "vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="], + + "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], + + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + + "workerd": ["workerd@1.20260401.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260401.1", "@cloudflare/workerd-darwin-arm64": "1.20260401.1", "@cloudflare/workerd-linux-64": "1.20260401.1", "@cloudflare/workerd-linux-arm64": "1.20260401.1", "@cloudflare/workerd-windows-64": "1.20260401.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-mUYCd+ohaWJWF5nhDzxugWaAD/DM8Dw0ze3B7bu8JaA7S70+XQJXcvcvwE8C4qGcxSdCyqjsrFzqxKubECDwzg=="], + + "wrangler": ["wrangler@4.80.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260401.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260401.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260401.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-2ZKF7uPeOZy65BGk3YfvqBCPo/xH1MrAlMmH9mVP+tCNBrTUMnwOHSj1HrZHgR8LttkAqhko0fGz+I4ax1rzyQ=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + + "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + + "yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@astrojs/cloudflare/wrangler": ["wrangler@4.59.2", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.10.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", "miniflare": "4.20260114.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260114.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260114.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-Z4xn6jFZTaugcOKz42xvRAYKgkVUERHVbuCJ5+f+gK+R6k12L02unakPGOA0L0ejhUl16dqDjKe4tmL9sedHcw=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], + + "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], + + "@astrojs/cloudflare/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.10.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20251221.0" }, "optionalPeers": ["workerd"] }, "sha512-/uII4vLQXhzCAZzEVeYAjFLBNg2nqTJ1JGzd2lRF6ItYe6U2zVoYGfeKpGx/EkBF6euiU+cyBXgMdtJih+nQ6g=="], + + "@astrojs/cloudflare/wrangler/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], + + "@astrojs/cloudflare/wrangler/miniflare": ["miniflare@4.20260114.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.14.0", "workerd": "1.20260114.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "^3.25.76" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-QwHT7S6XqGdQxIvql1uirH/7/i3zDEt0B/YBXTYzMfJtVCR4+ue3KPkU+Bl0zMxvpgkvjh9+eCHhJbKEqya70A=="], + + "@astrojs/cloudflare/wrangler/workerd": ["workerd@1.20260114.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260114.0", "@cloudflare/workerd-darwin-arm64": "1.20260114.0", "@cloudflare/workerd-linux-64": "1.20260114.0", "@cloudflare/workerd-linux-arm64": "1.20260114.0", "@cloudflare/workerd-windows-64": "1.20260114.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kTJ+jNdIllOzWuVA3NRQRvywP0T135zdCjAE2dAUY1BFbxM6fmMZV8BbskEoQ4hAODVQUfZQmyGctcwvVCKxFA=="], + + "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "boxen/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + + "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "widest-line/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "widest-line/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], + + "@astrojs/cloudflare/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], + + "@astrojs/cloudflare/wrangler/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + + "@astrojs/cloudflare/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260114.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-HNlsRkfNgardCig2P/5bp/dqDECsZ4+NU5XewqArWxMseqt3C5daSuptI620s4pn7Wr0ZKg7jVLH0PDEBkA+aA=="], + + "@astrojs/cloudflare/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260114.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qyE1UdFnAlxzb+uCfN/d9c8icch7XRiH49/DjoqEa+bCDihTuRS7GL1RmhVIqHJhb3pX3DzxmKgQZBDBL83Inw=="], + + "@astrojs/cloudflare/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260114.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0BLvAj/JPOabzads2ddDEfgExWTlD22pnwsuNbPwZAGTSZeQa3Y47eGUWyHk+rSGngknk++S7zHTGbKuG7RRg=="], + + "@astrojs/cloudflare/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260114.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-kPUmEtUxUWlr9PQ64kuhdK0qyo8idPe5IIXUgi7xCD7mDd6EOe5J7ugDpbfvfbYKEjx4DpLvN2t45izyI/Sodw=="], + + "@astrojs/cloudflare/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260114.0", "", { "os": "win32", "cpu": "x64" }, "sha512-MJnKgm6i1jZGyt2ZHQYCnRlpFTEZcK2rv9y7asS3KdVEXaDgGF8kOns5u6YL6/+eMogfZuHRjfDS+UqRTUYIFA=="], + + "boxen/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + } +} diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..a0935a6 --- /dev/null +++ b/worker/package.json @@ -0,0 +1,38 @@ +{ + "name": "meshcore-sar-stats", + "private": true, + "version": "0.0.0", + "type": "module", + "packageManager": "bun@1.3.8", + "scripts": { + "dev": "astro dev --host 0.0.0.0", + "build": "astro build", + "check": "astro check", + "test": "bun test", + "typecheck": "tsc --noEmit", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@astrojs/check": "^0.9.6", + "@astrojs/cloudflare": "^12.6.12", + "@astrojs/react": "^4.4.2", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@tailwindcss/vite": "^4.1.18", + "astro": "^5.17.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260403.1", + "@types/bun": "^1.3.11", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "typescript": "^5.9.3", + "wrangler": "^4.80.0" + } +} diff --git a/worker/public/.assetsignore b/worker/public/.assetsignore new file mode 100644 index 0000000..b7ccb6c --- /dev/null +++ b/worker/public/.assetsignore @@ -0,0 +1,2 @@ +_worker.js +_routes.json diff --git a/worker/public/favicon.png b/worker/public/favicon.png new file mode 100644 index 0000000..6b278d4 Binary files /dev/null and b/worker/public/favicon.png differ diff --git a/worker/schema.sql b/worker/schema.sql new file mode 100644 index 0000000..8f442c3 --- /dev/null +++ b/worker/schema.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS reports ( + report_id TEXT PRIMARY KEY, + device_key6 TEXT NOT NULL, + window_start TEXT NOT NULL, + window_end TEXT NOT NULL, + received_at TEXT NOT NULL, + app_version TEXT, + cf_country TEXT, + cf_region TEXT, + cf_city TEXT, + cf_latitude REAL, + cf_longitude REAL, + cf_colo TEXT, + pt_00 INTEGER NOT NULL DEFAULT 0, + pt_01 INTEGER NOT NULL DEFAULT 0, + pt_02 INTEGER NOT NULL DEFAULT 0, + pt_03 INTEGER NOT NULL DEFAULT 0, + pt_04 INTEGER NOT NULL DEFAULT 0, + pt_05 INTEGER NOT NULL DEFAULT 0, + pt_06 INTEGER NOT NULL DEFAULT 0, + pt_07 INTEGER NOT NULL DEFAULT 0, + pt_08 INTEGER NOT NULL DEFAULT 0, + pt_09 INTEGER NOT NULL DEFAULT 0, + pt_0a INTEGER NOT NULL DEFAULT 0, + pt_0b INTEGER NOT NULL DEFAULT 0, + pt_0c INTEGER NOT NULL DEFAULT 0, + pt_0d INTEGER NOT NULL DEFAULT 0, + pt_0e INTEGER NOT NULL DEFAULT 0, + pt_0f INTEGER NOT NULL DEFAULT 0, + decode_fail INTEGER NOT NULL DEFAULT 0, + path_mode_1b INTEGER NOT NULL DEFAULT 0, + path_mode_2b INTEGER NOT NULL DEFAULT 0, + path_mode_3b INTEGER NOT NULL DEFAULT 0, + path_mode_none INTEGER NOT NULL DEFAULT 0, + path_mode_unknown INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_reports_device_window_end +ON reports (device_key6, window_end); + +CREATE INDEX IF NOT EXISTS idx_reports_window_end +ON reports (window_end); diff --git a/worker/src/components/dashboard/dashboard-shell.tsx b/worker/src/components/dashboard/dashboard-shell.tsx new file mode 100644 index 0000000..89c85a0 --- /dev/null +++ b/worker/src/components/dashboard/dashboard-shell.tsx @@ -0,0 +1,809 @@ +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +type WindowKey = "24h" | "7d"; + +type PacketTypeEntry = { + key: string; + label: string; + total: number; +}; + +type PathModeEntry = { + key: string; + label: string; + total: number; +}; + +type ReporterSummary = { + key6: string; + lastSeen: string; + packetTotal: number; + country: string; + city: string; + latitude: number | null; + longitude: number | null; +}; + +type ChartPoint = { + label: string; + totalPackets: number; + reports: number; +}; + +type AppVersionEntry = { + version: string; + reporters: number; + packets: number; +}; + +type TrafficComposition = { + human: number; + overhead: number; + acks: number; +}; + +type MultiHopRatio = { + direct: number; + multiHop: number; +}; + +type CompositionPoint = { + label: string; + human: number; + overhead: number; +}; + +type DashboardResponse = { + generatedAt: string; + filter: { + windowKey: WindowKey; + label: string; + sinceIso: string; + bucket: "hour" | "day"; + }; + reportCount: number; + uniqueDevices: number; + decodedPackets: number; + decodeFailures: number; + packetTypeTotals: PacketTypeEntry[]; + pathModeTotals: PathModeEntry[]; + recentReporters: ReporterSummary[]; + chartPoints: ChartPoint[]; + appVersions: AppVersionEntry[]; + trafficComposition: TrafficComposition; + multiHopRatio: MultiHopRatio; + compositionOverTime: CompositionPoint[]; +}; + +const WINDOW_OPTIONS: Array<{ key: WindowKey; label: string }> = [ + { key: "24h", label: "24h" }, + { key: "7d", label: "7 days" }, +]; + +const PACKET_TYPE_INFO: Record = { + pt_00: { title: "FLOOD REQUEST", summary: "Encrypted request to a known peer" }, + pt_01: { title: "FLOOD RESPONSE", summary: "Encrypted reply to a request" }, + pt_02: { title: "FLOOD TEXT", summary: "Encrypted direct text with timestamp and retry flags" }, + pt_03: { title: "FLOOD ACK", summary: "4-byte acknowledgement for an earlier message" }, + pt_04: { title: "FLOOD ADVERTISEMENT", summary: "Signed node identity broadcast" }, + pt_05: { title: "FLOOD GROUP_TEXT", summary: "Encrypted channel text matched by channel hash" }, + pt_06: { title: "FLOOD GROUP_DATA", summary: "Encrypted channel data with type and length" }, + pt_07: { title: "FLOOD ANON_REQUEST", summary: "Request using an ephemeral sender key" }, + pt_08: { title: "FLOOD RETURNED_PATH", summary: "Return route back to sender, with optional bundled ACK" }, + pt_09: { title: "FLOOD TRACE_PATH", summary: "Direct trace that records SNR at each hop" }, + pt_0a: { title: "FLOOD MULTIPART", summary: "Wrapper for one packet in a multipart sequence" }, + pt_0b: { title: "FLOOD CONTROL", summary: "Discovery or other control data" }, + pt_0c: { title: "RESERVED 0x0C", summary: "Reserved protocol type" }, + pt_0d: { title: "RESERVED 0x0D", summary: "Reserved protocol type" }, + pt_0e: { title: "RESERVED 0x0E", summary: "Reserved protocol type" }, + pt_0f: { title: "RAW CUSTOM", summary: "Application-defined custom packet" }, +}; + +const PATH_MODE_ICONS: Record = { + path_mode_1b: "1B", + path_mode_2b: "2B", + path_mode_3b: "3B", + path_mode_none: "--", + path_mode_unknown: "??", +}; + +export function DashboardShell() { + const [windowKey, setWindowKey] = useState("24h"); + const [summary, setSummary] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let isCancelled = false; + + async function load() { + setIsLoading(true); + setError(null); + try { + const response = await fetch(`/api/dashboard?window=${windowKey}`, { + headers: { accept: "application/json" }, + }); + if (!response.ok) { + throw new Error(`Dashboard request failed (${response.status})`); + } + const nextSummary = (await response.json()) as DashboardResponse; + if (!isCancelled) setSummary(nextSummary); + } catch (nextError) { + if (!isCancelled) { + setError(nextError instanceof Error ? nextError.message : String(nextError)); + } + } finally { + if (!isCancelled) setIsLoading(false); + } + } + + void load(); + return () => { isCancelled = true; }; + }, [windowKey]); + + const activePacketTypes = useMemo( + () => (summary?.packetTypeTotals ?? []).filter((e) => e.total > 0), + [summary], + ); + const activePathModes = useMemo( + () => (summary?.pathModeTotals ?? []).filter((e) => e.total > 0), + [summary], + ); + const totalPackets = useMemo( + () => (summary ? summary.decodedPackets + summary.decodeFailures : 0), + [summary], + ); + const decodeRate = totalPackets > 0 + ? ((summary!.decodedPackets / totalPackets) * 100).toFixed(1) + : "0"; + const maxTrend = Math.max(...(summary?.chartPoints ?? []).map((p) => p.totalPackets), 1); + const multiHopTotal = (summary?.multiHopRatio.direct ?? 0) + (summary?.multiHopRatio.multiHop ?? 0); + const multiHopPct = multiHopTotal > 0 + ? ((summary!.multiHopRatio.multiHop / multiHopTotal) * 100).toFixed(1) + : "0"; + const comp = summary?.trafficComposition; + const compTotal = comp ? comp.human + comp.overhead + comp.acks : 0; + const humanPct = compTotal > 0 ? ((comp!.human / compTotal) * 100).toFixed(1) : "0"; + const nodeCount = summary?.uniqueDevices ?? 0; + const avgPerNode = nodeCount > 0 ? Math.round(totalPackets / nodeCount) : 0; + + return ( +
+ setWindowKey(v as WindowKey)}> + {/* Header */} +
+
+
+ MeshCore SAR +

MeshCore SAR

+
+

+ Aggregated observations from opt-in mesh nodes. Counts reflect what reporting nodes observed, not unique network packets. + Ratios and percentages are statistically valid; absolute numbers scale with reporter count. +

+
+
+ + {WINDOW_OPTIONS.map((o) => ( + + {o.label} + + ))} + + {summary && ( + + Updated {formatRelative(summary.generatedAt)} + + )} +
+
+ + {error && ( +
+ {error} + +
+ )} + + + {/* Key metrics */} +
+ + + + + + +
+ + {/* Traffic breakdown */} +
+ + + Traffic Breakdown + Observed traffic composition across all reporting nodes + + + {comp && compTotal > 0 ? ( + <> +
+
+
+
+
+
+
+
+
{comp.human.toLocaleString()}
+
Messages
+
+
+
+
{comp.acks.toLocaleString()}
+
Acks
+
+
+
+
{comp.overhead.toLocaleString()}
+
Overhead
+
+
+ + ) : ( + + )} + + + + + + Messages vs Protocol Overhead + + Human messages (text + group text) compared to protocol overhead (acks, advertisements, routing, control) over time. + + + + {summary?.compositionOverTime.length ? ( + + ) : ( + + )} + + +
+ + {/* Protocol breakdown */} +
+ + + Protocol Packet Types + + MeshCore protocol uses 16 packet type codes (0x00 - 0x0F). + Showing types with traffic in this window. + + + + {activePacketTypes.length ? ( +
+ {activePacketTypes.map((entry) => { + const pct = totalPackets > 0 ? ((entry.total / totalPackets) * 100).toFixed(1) : "0"; + const info = PACKET_TYPE_INFO[entry.key]; + return ( +
+
+ {entry.key.replace("pt_", "0x").toUpperCase()} +
+
+
+ {entry.label} + {pct}% +
+
+ {info?.title ?? entry.key} +
+

+ {info?.summary ?? ""} +

+
+
+
+
+ {entry.total.toLocaleString()} obs. +
+
+
+ ); + })} +
+ ) : ( + + )} + + + + + + Path Routing Modes + + Path hash byte length determines routing precision. + Longer hashes allow more specific multi-hop paths. + + + + {activePathModes.length ? ( +
+ {activePathModes.map((entry) => { + const pct = totalPackets > 0 ? ((entry.total / totalPackets) * 100).toFixed(1) : "0"; + return ( +
+
+ {PATH_MODE_ICONS[entry.key] ?? "?"} +
+
{entry.total.toLocaleString()}
+
{entry.label}
+
{pct}%
+
+ ); + })} +
+ ) : ( + + )} +
+
+
+ + {/* Map + Traffic trend */} +
+ + + Observations Over Time + + Per-node average observations per {summary?.filter.bucket ?? "time"} bucket. + Normalizing by reporter count removes the bias of more nodes = higher numbers. + + + + {isLoading && !summary ? ( + + ) : summary?.chartPoints.length ? ( + + ) : ( + + )} + + +
+ + {/* App versions */} +
+ + + App Versions + Distribution of reporting app versions by node count and observations + + + {summary?.appVersions.length ? ( +
+ {summary.appVersions.map((entry) => { + const maxPkts = summary.appVersions[0].packets; + return ( +
+
+ {entry.version} + + {entry.reporters} {entry.reporters === 1 ? "node" : "nodes"} / {entry.packets.toLocaleString()} obs. + +
+
+
+
+
+ ); + })} +
+ ) : ( + + )} + + +
+
+
+
+ ); +} + +// --- Composition stacked area chart --- + +const MIN_CHART_W = 320; +const COMPOSITION_CHART_H = 236; + +function useResponsiveChartWidth(minWidth = MIN_CHART_W) { + const [element, setElement] = useState(null); + const [width, setWidth] = useState(minWidth); + + useEffect(() => { + if (!element) return; + + const sync = () => setWidth(Math.max(element.clientWidth, minWidth)); + sync(); + + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(sync); + observer.observe(element); + + return () => { observer.disconnect(); }; + }, [element, minWidth]); + + return { setElement, width }; +} + +function CompositionChart({ points }: { points: CompositionPoint[] }) { + const [hover, setHover] = useState(null); + const { setElement, width } = useResponsiveChartWidth(); + const count = points.length; + if (count === 0) return null; + + const maxVal = Math.max(...points.map((p) => p.human + p.overhead), 1); + const pad = { top: 16, right: 16, bottom: 32, left: 48 }; + const innerW = Math.max(width - pad.left - pad.right, 240); + const innerH = COMPOSITION_CHART_H - pad.top - pad.bottom; + + const xs = points.map((_, i) => i / Math.max(count - 1, 1)); + + // stacked: overhead on bottom, human on top + const overheadYs = points.map((p) => 1 - p.overhead / maxVal); + const totalYs = points.map((p) => 1 - (p.overhead + p.human) / maxVal); + + const overheadArea = buildAreaPath(xs, overheadYs, innerW, innerH); + const humanArea = buildStackedAreaPath(xs, totalYs, overheadYs, innerW, innerH); + + const labelStep = Math.max(1, Math.floor(count / 6)); + const gridLines = niceGridLines(maxVal, 3); + + return ( +
+ setHover(null)} + > + + + + + + + + + + + + {gridLines.map((val) => { + const y = (1 - val / maxVal) * innerH; + return ( + + + + {formatCompact(val)} + + + ); + })} + + + + + + {/* X labels */} + {points.map((p, i) => (i % labelStep === 0 || i === count - 1) ? ( + + {p.label.slice(5)} + + ) : null)} + + {/* hover zones */} + {points.map((point, i) => ( + setHover(i)} + /> + ))} + + {hover !== null && ( + + )} + + + {hover !== null && ( +
+
{points[hover].label}
+
Messages: {points[hover].human.toLocaleString()}
+
Overhead: {points[hover].overhead.toLocaleString()}
+
+ )} + +
+ Messages + Overhead +
+
+ ); +} + +function buildAreaPath(xs: number[], ys: number[], w: number, h: number): string { + const line = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * w},${ys[i] * h}`).join(" "); + return `${line} L${xs[xs.length - 1] * w},${h} L0,${h} Z`; +} + +function buildStackedAreaPath(xs: number[], topYs: number[], bottomYs: number[], w: number, h: number): string { + const top = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * w},${topYs[i] * h}`).join(" "); + const bottom = [...xs].reverse().map((x, i) => `L${x * w},${bottomYs[xs.length - 1 - i] * h}`).join(" "); + return `${top} ${bottom} Z`; +} + +// --- Traffic trend chart --- + +const CHART_H = 240; +const CHART_PAD = { top: 20, right: 16, bottom: 32, left: 48 }; + +function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; maxValue: number }) { + const [hover, setHover] = useState(null); + const { setElement, width } = useResponsiveChartWidth(); + const count = points.length; + if (count === 0) return null; + + // Normalize: per-node average per bucket + const normalized = points.map((p) => ({ + ...p, + perNode: p.reports > 0 ? Math.round(p.totalPackets / p.reports) : 0, + })); + const maxValue = Math.max(...normalized.map((p) => p.perNode), 1); + + const innerW = Math.max(width - CHART_PAD.left - CHART_PAD.right, 240); + const innerH = CHART_H - CHART_PAD.top - CHART_PAD.bottom; + + const peakIdx = normalized.reduce((best, p, i) => (p.perNode > normalized[best].perNode ? i : best), 0); + + const gridLines = niceGridLines(maxValue, 4); + + const xs = normalized.map((_, i) => i / Math.max(count - 1, 1)); + const ys = normalized.map((p) => 1 - p.perNode / maxValue); + + const linePath = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * innerW},${ys[i] * innerH}`).join(" "); + const areaPath = `${linePath} L${xs[xs.length - 1] * innerW},${innerH} L0,${innerH} Z`; + + // X-axis labels: show ~6 evenly spaced + const labelStep = Math.max(1, Math.floor(count / 6)); + const xLabels = points + .map((p, i) => ({ i, label: p.label.slice(5) })) + .filter((_, i) => i % labelStep === 0 || i === count - 1); + + return ( +
+ setHover(null)} + > + + + + + + + + + + + + {/* Y grid lines */} + {gridLines.map((val) => { + const y = (1 - val / maxValue) * innerH; + return ( + + + + {formatCompact(val)} + + + ); + })} + + {/* baseline */} + + + {/* area fill */} + + + {/* line */} + + + {/* peak dot */} + + + {/* peak label */} + + {normalized[peakIdx].perNode.toLocaleString()} + + + {/* X-axis labels */} + {xLabels.map(({ i, label }) => ( + + {label} + + ))} + + {/* invisible hover zones */} + {points.map((point, i) => { + const sliceW = innerW / count; + return ( + setHover(i)} + /> + ); + })} + + {/* hover indicator */} + {hover !== null && ( + <> + + + + )} + + + {/* hover tooltip */} + {hover !== null && ( +
+
{normalized[hover].perNode.toLocaleString()} avg/node
+
{normalized[hover].totalPackets.toLocaleString()} total from {normalized[hover].reports} {normalized[hover].reports === 1 ? "node" : "nodes"}
+
{normalized[hover].label}
+
+ )} +
+ ); +} + +function niceGridLines(max: number, count: number): number[] { + if (max <= 0) return [0]; + const rough = max / count; + const magnitude = 10 ** Math.floor(Math.log10(rough)); + const residual = rough / magnitude; + const nice = residual <= 1.5 ? 1 : residual <= 3 ? 2 : residual <= 7 ? 5 : 10; + const step = nice * magnitude; + const lines: number[] = []; + for (let v = step; v <= max; v += step) { + lines.push(Math.round(v)); + } + return lines; +} + +function formatCompact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`; + return String(n); +} + +function MetricCard({ label, value, note }: { label: string; value: number | string; note: string }) { + return ( + + + {label} + {typeof value === "number" ? value.toLocaleString() : value} + {note} + + + ); +} + +function EmptyState({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +function formatRelative(value: string) { + const diffMs = Date.now() - new Date(value).getTime(); + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + return `${Math.floor(diffHr / 24)}d ago`; +} diff --git a/worker/src/components/ui/badge.tsx b/worker/src/components/ui/badge.tsx new file mode 100644 index 0000000..8f48d14 --- /dev/null +++ b/worker/src/components/ui/badge.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground", + secondary: "border-transparent bg-secondary text-secondary-foreground", + destructive: "border-transparent bg-destructive text-destructive-foreground", + outline: "text-foreground bg-card/60", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/worker/src/components/ui/button.tsx b/worker/src/components/ui/button.tsx new file mode 100644 index 0000000..0bbdf31 --- /dev/null +++ b/worker/src/components/ui/button.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +type ButtonProps = React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border bg-card hover:bg-muted", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90", + ghost: "hover:bg-muted", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3", + lg: "h-10 rounded-md px-6", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: ButtonProps) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants, type ButtonProps }; diff --git a/worker/src/components/ui/card.tsx b/worker/src/components/ui/card.tsx new file mode 100644 index 0000000..42d2df2 --- /dev/null +++ b/worker/src/components/ui/card.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +export { Card, CardHeader, CardTitle, CardDescription, CardContent }; diff --git a/worker/src/components/ui/table.tsx b/worker/src/components/ui/table.tsx new file mode 100644 index 0000000..c677941 --- /dev/null +++ b/worker/src/components/ui/table.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ), +); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }; diff --git a/worker/src/components/ui/tabs.tsx b/worker/src/components/ui/tabs.tsx new file mode 100644 index 0000000..b86c93f --- /dev/null +++ b/worker/src/components/ui/tabs.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/worker/src/index.css b/worker/src/index.css new file mode 100644 index 0000000..6074277 --- /dev/null +++ b/worker/src/index.css @@ -0,0 +1,99 @@ +@import "tailwindcss"; + +@theme { + --font-sans: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; + --font-mono: "IBM Plex Mono", "SFMono-Regular", monospace; + + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); +} + +@layer base { + :root { + --background: 220 33% 97%; + --foreground: 222 47% 11%; + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + --primary: 217 91% 60%; + --primary-foreground: 0 0% 100%; + --secondary: 220 50% 93%; + --secondary-foreground: 222 47% 11%; + --muted: 220 26% 93%; + --muted-foreground: 220 13% 46%; + --accent: 217 91% 60%; + --accent-foreground: 0 0% 100%; + --destructive: 0 72% 51%; + --destructive-foreground: 0 0% 100%; + --border: 220 20% 88%; + --input: 220 20% 88%; + --ring: 217 91% 60%; + --radius: 1rem; + --chart-grid: 220 20% 88%; + --chart-label: 220 13% 46%; + --chart-primary: 217 91% 60%; + --chart-primary-light: 217 91% 65%; + --chart-primary-dark: 217 70% 50%; + --chart-primary-accent: 217 91% 45%; + --chart-secondary: 206 22% 75%; + } + + .dark { + --background: 222 47% 8%; + --foreground: 220 20% 90%; + --card: 222 40% 12%; + --card-foreground: 220 20% 90%; + --primary: 217 91% 60%; + --primary-foreground: 0 0% 100%; + --secondary: 220 30% 18%; + --secondary-foreground: 220 20% 90%; + --muted: 220 20% 16%; + --muted-foreground: 220 13% 55%; + --accent: 217 91% 60%; + --accent-foreground: 0 0% 100%; + --destructive: 0 72% 51%; + --destructive-foreground: 0 0% 100%; + --border: 220 20% 20%; + --input: 220 20% 20%; + --ring: 217 91% 60%; + --chart-grid: 220 20% 22%; + --chart-label: 220 13% 50%; + --chart-primary: 217 91% 65%; + --chart-primary-light: 217 91% 70%; + --chart-primary-dark: 217 70% 55%; + --chart-primary-accent: 217 91% 55%; + --chart-secondary: 220 20% 35%; + } + + * { + border-color: hsl(var(--border)); + } + + html { + font-family: var(--font-sans); + color-scheme: light dark; + } + + body { + min-height: 100vh; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + } +} diff --git a/worker/src/layouts/BaseLayout.astro b/worker/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..6fc0c54 --- /dev/null +++ b/worker/src/layouts/BaseLayout.astro @@ -0,0 +1,28 @@ +--- +interface Props { + title?: string; +} + +const { title = "MeshCore SAR RX Stats" } = Astro.props; + +import "../index.css"; +--- + + + + + + + + {title} + + + + + + diff --git a/worker/src/lib/utils.ts b/worker/src/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/worker/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/worker/src/pages/index.astro b/worker/src/pages/index.astro new file mode 100644 index 0000000..fb72b6b --- /dev/null +++ b/worker/src/pages/index.astro @@ -0,0 +1,8 @@ +--- +import BaseLayout from "../layouts/BaseLayout.astro"; +import { DashboardShell } from "@/components/dashboard/dashboard-shell"; +--- + + + + diff --git a/worker/test/index.test.ts b/worker/test/index.test.ts new file mode 100644 index 0000000..94ad53a --- /dev/null +++ b/worker/test/index.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from 'bun:test'; + +import { + REPORT_INSERT_SQL, + buildWindowFilter, + createEmptyCounts, + extractCfGeo, + loadDashboardSummary, + summarizeRows, + validateIngestPayload, +} from '../worker/stats'; + +describe('validateIngestPayload', () => { + test('accepts a complete payload with fixed columns', () => { + const counts = createEmptyCounts(); + counts.pt_04 = 12; + counts.path_mode_2b = 4; + + const payload = validateIngestPayload({ + reportId: 'a1b2c3d4e5f6:2026-04-03T10:00:00.000Z', + deviceKey6: 'a1b2c3d4e5f6', + windowStart: '2026-04-03T10:00:00.000Z', + windowEnd: '2026-04-03T10:05:00.000Z', + appVersion: '2026.0402.1+44', + counts, + }); + + expect(payload.counts.pt_04).toBe(12); + expect(payload.counts.path_mode_2b).toBe(4); + }); + + test('rejects missing fixed count keys', () => { + expect(() => + validateIngestPayload({ + reportId: 'a1b2c3d4e5f6:2026-04-03T10:00:00.000Z', + deviceKey6: 'a1b2c3d4e5f6', + windowStart: '2026-04-03T10:00:00.000Z', + windowEnd: '2026-04-03T10:05:00.000Z', + appVersion: '2026.0402.1+44', + counts: {}, + }), + ).toThrow('counts.pt_00 must be a number.'); + }); +}); + +describe('worker helpers', () => { + test('uses insert-or-ignore semantics for idempotent reports', () => { + expect(REPORT_INSERT_SQL).toContain('INSERT OR IGNORE INTO reports'); + }); + + test('extractCfGeo reads Cloudflare request metadata', () => { + const request = new Request('https://example.com/') as Request & { + cf?: Record; + }; + request.cf = { + country: 'SI', + region: 'Ljubljana', + city: 'Ljubljana', + latitude: '46.0569', + longitude: '14.5058', + colo: 'LJU', + }; + + const geo = extractCfGeo(request); + + expect(geo.country).toBe('SI'); + expect(geo.latitude).toBe(46.0569); + expect(geo.longitude).toBe(14.5058); + expect(geo.colo).toBe('LJU'); + }); + + test('summarizeRows aggregates packet and path mode totals', () => { + const filter = buildWindowFilter('24h', new Date('2026-04-03T12:00:00.000Z')); + const rows = [ + { + ...createEmptyCounts(), + report_id: 'a1', + device_key6: 'a1b2c3d4e5f6', + window_start: '2026-04-03T10:00:00.000Z', + window_end: '2026-04-03T10:05:00.000Z', + received_at: '2026-04-03T10:05:03.000Z', + app_version: '2026.0402.1+44', + cf_country: 'SI', + cf_region: 'Ljubljana', + cf_city: 'Ljubljana', + cf_latitude: 46.0569, + cf_longitude: 14.5058, + cf_colo: 'LJU', + pt_04: 12, + path_mode_2b: 12, + }, + { + ...createEmptyCounts(), + report_id: 'a2', + device_key6: '001122334455', + window_start: '2026-04-03T11:00:00.000Z', + window_end: '2026-04-03T11:05:00.000Z', + received_at: '2026-04-03T11:05:02.000Z', + app_version: '2026.0402.1+44', + cf_country: 'DE', + cf_region: 'Berlin', + cf_city: 'Berlin', + cf_latitude: 52.52, + cf_longitude: 13.405, + cf_colo: 'FRA', + pt_05: 3, + decode_fail: 1, + path_mode_none: 1, + path_mode_3b: 2, + }, + ]; + + const summary = summarizeRows(rows, filter); + + expect(summary.reportCount).toBe(2); + expect(summary.uniqueDevices).toBe(2); + expect(summary.decodedPackets).toBe(15); + expect(summary.decodeFailures).toBe(1); + expect(summary.pathModeTotals[0]?.total).toBe(12); + expect(summary.locationPoints).toHaveLength(2); + }); + + test('loadDashboardSummary reads D1 rows for the selected window', async () => { + const rows = [ + { + ...createEmptyCounts(), + report_id: 'a1', + device_key6: 'a1b2c3d4e5f6', + window_start: '2026-04-03T10:00:00.000Z', + window_end: '2026-04-03T10:05:00.000Z', + received_at: '2026-04-03T10:05:03.000Z', + app_version: '2026.0402.1+44', + cf_country: 'SI', + cf_region: 'Ljubljana', + cf_city: 'Ljubljana', + cf_latitude: 46.0569, + cf_longitude: 14.5058, + cf_colo: 'LJU', + pt_04: 5, + }, + ]; + + const env = { + DB: { + prepare() { + return { + bind() { + return { + async all() { + return { results: rows }; + }, + }; + }, + }; + }, + }, + } as any; + + const summary = await loadDashboardSummary( + env, + '24h', + new Date('2026-04-03T12:00:00.000Z'), + ); + + expect(summary.reportCount).toBe(1); + expect(summary.packetTypeTotals[0]?.key).toBe('pt_04'); + expect(summary.packetTypeTotals[0]?.total).toBe(5); + }); +}); diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 0000000..559e828 --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [ + ".astro/types.d.ts", + "**/*" + ], + "exclude": [ + "dist", + "node_modules" + ], + "compilerOptions": { + "baseUrl": ".", + "jsx": "react-jsx", + "jsxImportSource": "react", + "paths": { + "@/*": ["./src/*"] + }, + "types": ["@cloudflare/workers-types", "bun-types"] + } +} diff --git a/worker/worker/index.ts b/worker/worker/index.ts new file mode 100644 index 0000000..f91f43c --- /dev/null +++ b/worker/worker/index.ts @@ -0,0 +1,128 @@ +import { + COUNT_KEYS, + REPORT_INSERT_SQL, + extractCfGeo, + jsonHeaders, + loadDashboardSummary, + purgeOldReports, + validateIngestPayload, + type Env, + type IngestPayload, +} from "./stats"; + +const ROUTES = { + "/api/ingest": handleIngest, + "/api/dashboard": handleDashboard, +} as const; + +export default { + async fetch(request: Request, env: Env): Promise { + const pathname = new URL(request.url).pathname; + const route = Object.entries(ROUTES).find(([prefix]) => + pathname.startsWith(prefix), + ); + if (!route) { + return new Response("Not Found", { status: 404 }); + } + try { + return await route[1](request, env); + } catch (error) { + console.error(`Worker route failed for ${pathname}:`, error); + return Response.json( + { error: "Internal Server Error" }, + { + headers: jsonHeaders, + status: 500, + }, + ); + } + }, +}; + +async function handleIngest(request: Request, env: Env): Promise { + if (request.method !== "POST") { + return Response.json( + { error: "Method not allowed" }, + { + headers: jsonHeaders, + status: 405, + }, + ); + } + + let payload: IngestPayload; + try { + payload = validateIngestPayload(await request.json()); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "Invalid payload" }, + { + headers: jsonHeaders, + status: 400, + }, + ); + } + + const geo = extractCfGeo(request); + const values = [ + payload.reportId, + payload.deviceKey6, + payload.windowStart, + payload.windowEnd, + new Date().toISOString(), + payload.appVersion, + geo.country, + geo.region, + geo.city, + geo.latitude, + geo.longitude, + geo.colo, + ...COUNT_KEYS.map((key) => payload.counts[key]), + ]; + + const result = await env.DB.prepare(REPORT_INSERT_SQL).bind(...values).run(); + const changes = Number((result.meta as { changes?: number }).changes ?? 0); + + // Purge reports older than 7 days (best-effort, don't block response) + void purgeOldReports(env).catch(() => {}); + + return Response.json( + { + ok: true, + duplicate: changes === 0, + }, + { + headers: jsonHeaders, + }, + ); +} + +async function handleDashboard(request: Request, env: Env): Promise { + if (request.method !== "GET") { + return Response.json( + { error: "Method not allowed" }, + { + headers: jsonHeaders, + status: 405, + }, + ); + } + + const url = new URL(request.url); + const windowParam = url.searchParams.get("window"); + const summary = await loadDashboardSummary(env, windowParam); + const cacheTtl = windowParam === "7d" ? 86400 : 60; + return Response.json( + { + generatedAt: new Date().toISOString(), + ...summary, + }, + { + headers: { + ...jsonHeaders, + "cache-control": `public, max-age=${cacheTtl}, s-maxage=${cacheTtl}`, + "cdn-cache-control": `max-age=${cacheTtl}`, + }, + }, + ); +} diff --git a/worker/worker/stats.ts b/worker/worker/stats.ts new file mode 100644 index 0000000..aa11bd6 --- /dev/null +++ b/worker/worker/stats.ts @@ -0,0 +1,516 @@ +export const PACKET_TYPE_KEYS = [ + "pt_00", + "pt_01", + "pt_02", + "pt_03", + "pt_04", + "pt_05", + "pt_06", + "pt_07", + "pt_08", + "pt_09", + "pt_0a", + "pt_0b", + "pt_0c", + "pt_0d", + "pt_0e", + "pt_0f", +] as const; + +export const PATH_MODE_KEYS = [ + "path_mode_1b", + "path_mode_2b", + "path_mode_3b", + "path_mode_none", + "path_mode_unknown", +] as const; + +export const COUNT_KEYS = [ + ...PACKET_TYPE_KEYS, + "decode_fail", + ...PATH_MODE_KEYS, +] as const; + +const REPORT_COLUMNS = [ + "report_id", + "device_key6", + "window_start", + "window_end", + "received_at", + "app_version", + "cf_country", + "cf_region", + "cf_city", + "cf_latitude", + "cf_longitude", + "cf_colo", + ...COUNT_KEYS, +] as const; + +const PACKET_TYPE_LABELS: Record<(typeof PACKET_TYPE_KEYS)[number], string> = { + pt_00: "Request", + pt_01: "Response", + pt_02: "Text message", + pt_03: "Ack", + pt_04: "Advertisement", + pt_05: "Group text", + pt_06: "Group datagram", + pt_07: "Anonymous request", + pt_08: "Returned path", + pt_09: "Trace path", + pt_0a: "Multipart packet", + pt_0b: "Control packet", + pt_0c: "Reserved 0x0C", + pt_0d: "Reserved 0x0D", + pt_0e: "Reserved 0x0E", + pt_0f: "Custom packet", +}; + +const PATH_MODE_LABELS: Record<(typeof PATH_MODE_KEYS)[number], string> = { + path_mode_1b: "1-byte path hash", + path_mode_2b: "2-byte path hash", + path_mode_3b: "3-byte path hash", + path_mode_none: "No path bytes", + path_mode_unknown: "Unknown path mode", +}; + +const WINDOW_OPTIONS = { + "24h": { + label: "Last 24 hours", + durationMs: 24 * 60 * 60 * 1000, + bucket: "hour", + }, + "7d": { + label: "Last 7 days", + durationMs: 7 * 24 * 60 * 60 * 1000, + bucket: "day", + }, +} as const; + +const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +export const jsonHeaders = { + "content-type": "application/json; charset=utf-8", +} as const; + +type CountKey = (typeof COUNT_KEYS)[number]; +type PacketTypeKey = (typeof PACKET_TYPE_KEYS)[number]; +type PathModeKey = (typeof PATH_MODE_KEYS)[number]; +type WindowKey = keyof typeof WINDOW_OPTIONS; + +export type Counts = Record; + +export interface Env { + DB: D1Database; +} + +export interface IngestPayload { + reportId: string; + deviceKey6: string; + windowStart: string; + windowEnd: string; + appVersion: string; + counts: Counts; +} + +export interface CfGeo { + country: string | null; + region: string | null; + city: string | null; + latitude: number | null; + longitude: number | null; + colo: string | null; +} + +export interface ReportRow extends Counts { + report_id: string; + device_key6: string; + window_start: string; + window_end: string; + received_at: string; + app_version: string | null; + cf_country: string | null; + cf_region: string | null; + cf_city: string | null; + cf_latitude: number | null; + cf_longitude: number | null; + cf_colo: string | null; +} + +export interface WindowFilter { + windowKey: WindowKey; + label: string; + sinceIso: string; + bucket: "hour" | "day"; +} + +export interface ChartPoint { + label: string; + totalPackets: number; + reports: number; +} + +export interface LocationPoint { + key6: string; + city: string; + country: string; + latitude: number; + longitude: number; +} + +export interface ReporterSummary { + key6: string; + lastSeen: string; + packetTotal: number; + country: string; + city: string; + latitude: number | null; + longitude: number | null; +} + +export interface AppVersionEntry { + version: string; + reporters: number; + packets: number; +} + +export interface TrafficComposition { + human: number; + overhead: number; + acks: number; +} + +export interface MultiHopRatio { + direct: number; + multiHop: number; +} + +export interface CompositionPoint { + label: string; + human: number; + overhead: number; +} + +export interface DashboardSummary { + filter: WindowFilter; + reportCount: number; + uniqueDevices: number; + decodedPackets: number; + decodeFailures: number; + packetTypeTotals: Array<{ key: PacketTypeKey; label: string; total: number }>; + pathModeTotals: Array<{ key: PathModeKey; label: string; total: number }>; + recentReporters: ReporterSummary[]; + chartPoints: ChartPoint[]; + locationPoints: LocationPoint[]; + appVersions: AppVersionEntry[]; + trafficComposition: TrafficComposition; + multiHopRatio: MultiHopRatio; + compositionOverTime: CompositionPoint[]; +} + +export const REPORT_INSERT_SQL = ` +INSERT OR IGNORE INTO reports ( + ${REPORT_COLUMNS.join(", ")} +) VALUES ( + ${REPORT_COLUMNS.map(() => "?").join(", ")} +)`.trim(); + +export function createEmptyCounts(): Counts { + return Object.fromEntries( + COUNT_KEYS.map((key) => [key, 0]), + ) as Counts; +} + +export function validateIngestPayload(payload: unknown): IngestPayload { + if (typeof payload !== "object" || payload === null) { + throw new Error("Body must be a JSON object."); + } + const record = payload as Record; + const reportId = asTrimmedString(record.reportId, "reportId"); + const deviceKey6 = asTrimmedString(record.deviceKey6, "deviceKey6"); + if (!/^[0-9a-f]{12}$/.test(deviceKey6)) { + throw new Error("deviceKey6 must be 12 lowercase hex characters."); + } + const windowStart = asIsoString(record.windowStart, "windowStart"); + const windowEnd = asIsoString(record.windowEnd, "windowEnd"); + if (Date.parse(windowEnd) < Date.parse(windowStart)) { + throw new Error("windowEnd must not be earlier than windowStart."); + } + const appVersion = asTrimmedString(record.appVersion, "appVersion"); + const counts = validateCounts(record.counts); + return { + reportId, + deviceKey6, + windowStart, + windowEnd, + appVersion, + counts, + }; +} + +export function extractCfGeo(request: Request): CfGeo { + const cf = (request as Request & { cf?: Record }).cf; + return { + country: asNullableString(cf?.country), + region: asNullableString(cf?.region), + city: asNullableString(cf?.city), + latitude: asNullableNumber(cf?.latitude), + longitude: asNullableNumber(cf?.longitude), + colo: asNullableString(cf?.colo), + }; +} + +export function buildWindowFilter( + requestedWindow: string | null, + now: Date = new Date(), +): WindowFilter { + const windowKey = + requestedWindow === "24h" || + requestedWindow === "7d" + ? requestedWindow + : "24h"; + const option = WINDOW_OPTIONS[windowKey]; + return { + windowKey, + label: option.label, + sinceIso: new Date(now.getTime() - option.durationMs).toISOString(), + bucket: option.bucket, + }; +} + +export async function purgeOldReports( + env: Env, + now: Date = new Date(), +): Promise { + const cutoff = new Date(now.getTime() - RETENTION_MS).toISOString(); + const result = await env.DB.prepare( + "DELETE FROM reports WHERE window_end < ?", + ) + .bind(cutoff) + .run(); + return Number((result.meta as { changes?: number }).changes ?? 0); +} + +export async function loadDashboardSummary( + env: Env, + requestedWindow: string | null, + now: Date = new Date(), +): Promise { + const filter = buildWindowFilter(requestedWindow, now); + const query = await env.DB.prepare( + "SELECT * FROM reports WHERE window_end >= ? ORDER BY window_end DESC", + ) + .bind(filter.sinceIso) + .all(); + const rows = (query.results ?? []) as ReportRow[]; + return summarizeRows(rows, filter); +} + +export function summarizeRows( + rows: ReportRow[], + filter: WindowFilter, +): DashboardSummary { + const packetTypeTotals = PACKET_TYPE_KEYS.map((key) => ({ + key, + label: PACKET_TYPE_LABELS[key], + total: sumRows(rows, key), + })).sort((left, right) => right.total - left.total); + const pathModeTotals = PATH_MODE_KEYS.map((key) => ({ + key, + label: PATH_MODE_LABELS[key], + total: sumRows(rows, key), + })).sort((left, right) => right.total - left.total); + const decodedPackets = PACKET_TYPE_KEYS.reduce( + (total, key) => total + sumRows(rows, key), + 0, + ); + const decodeFailures = sumRows(rows, "decode_fail"); + const reporterMap = new Map(); + const chartBuckets = new Map(); + + // per-version and per-colo accumulators + const versionMap = new Map; packets: number }>(); + const compositionBuckets = new Map(); + + for (const row of rows) { + const packetTotal = + decodeFailuresForRow(row) + + PACKET_TYPE_KEYS.reduce((total, key) => total + row[key], 0); + const existingReporter = reporterMap.get(row.device_key6); + if (!existingReporter) { + reporterMap.set(row.device_key6, { + key6: row.device_key6, + lastSeen: row.window_end, + packetTotal, + country: row.cf_country ?? "Unknown", + city: row.cf_city ?? row.cf_region ?? "Unknown", + latitude: row.cf_latitude, + longitude: row.cf_longitude, + }); + } else { + existingReporter.packetTotal += packetTotal; + if (row.window_end > existingReporter.lastSeen) { + existingReporter.lastSeen = row.window_end; + existingReporter.country = row.cf_country ?? existingReporter.country; + existingReporter.city = + row.cf_city ?? row.cf_region ?? existingReporter.city; + existingReporter.latitude = row.cf_latitude; + existingReporter.longitude = row.cf_longitude; + } + } + + const bucketKey = formatBucket(row.window_end, filter.bucket); + const existingBucket = chartBuckets.get(bucketKey); + if (existingBucket) { + existingBucket.totalPackets += packetTotal; + existingBucket.reports += 1; + } else { + chartBuckets.set(bucketKey, { + label: bucketKey, + totalPackets: packetTotal, + reports: 1, + }); + } + + // app version + const ver = row.app_version ?? "unknown"; + const verEntry = versionMap.get(ver); + if (verEntry) { + verEntry.reporters.add(row.device_key6); + verEntry.packets += packetTotal; + } else { + versionMap.set(ver, { reporters: new Set([row.device_key6]), packets: packetTotal }); + } + + // composition over time (human = text + group_text, overhead = rest) + const humanPackets = row.pt_02 + row.pt_05; + const overheadPackets = packetTotal - humanPackets; + const compBucket = compositionBuckets.get(bucketKey); + if (compBucket) { + compBucket.human += humanPackets; + compBucket.overhead += overheadPackets; + } else { + compositionBuckets.set(bucketKey, { human: humanPackets, overhead: overheadPackets }); + } + } + + const recentReporters = [...reporterMap.values()] + .sort((left, right) => right.lastSeen.localeCompare(left.lastSeen)) + .slice(0, 12); + const locationPoints = recentReporters + .filter( + ( + reporter, + ): reporter is ReporterSummary & { latitude: number; longitude: number } => + reporter.latitude !== null && reporter.longitude !== null, + ) + .map((reporter) => ({ + key6: reporter.key6, + city: reporter.city, + country: reporter.country, + latitude: reporter.latitude, + longitude: reporter.longitude, + })); + + // human = text messages (pt_02 + pt_05), acks = pt_03, overhead = everything else + const humanTotal = sumRows(rows, "pt_02") + sumRows(rows, "pt_05"); + const acksTotal = sumRows(rows, "pt_03"); + const overheadTotal = decodedPackets - humanTotal - acksTotal; + + // multi-hop: direct = path_mode_none, multiHop = 1b+2b+3b + const directTotal = sumRows(rows, "path_mode_none"); + const multiHopTotal = sumRows(rows, "path_mode_1b") + sumRows(rows, "path_mode_2b") + sumRows(rows, "path_mode_3b"); + + return { + filter, + reportCount: rows.length, + uniqueDevices: reporterMap.size, + decodedPackets, + decodeFailures, + packetTypeTotals, + pathModeTotals, + recentReporters, + chartPoints: [...chartBuckets.values()].sort((left, right) => + left.label.localeCompare(right.label), + ), + locationPoints, + appVersions: [...versionMap.entries()] + .map(([version, entry]) => ({ version, reporters: entry.reporters.size, packets: entry.packets })) + .sort((left, right) => right.packets - left.packets), + trafficComposition: { human: humanTotal, overhead: overheadTotal, acks: acksTotal }, + multiHopRatio: { direct: directTotal, multiHop: multiHopTotal }, + compositionOverTime: [...compositionBuckets.entries()] + .map(([label, entry]) => ({ label, ...entry })) + .sort((left, right) => left.label.localeCompare(right.label)), + }; +} + +function validateCounts(value: unknown): Counts { + if (typeof value !== "object" || value === null) { + throw new Error("counts must be an object."); + } + const record = value as Record; + const counts = createEmptyCounts(); + for (const key of COUNT_KEYS) { + const rawValue = record[key]; + if (typeof rawValue !== "number" || !Number.isFinite(rawValue)) { + throw new Error(`counts.${key} must be a number.`); + } + if (rawValue < 0) { + throw new Error(`counts.${key} must be zero or greater.`); + } + counts[key] = Math.trunc(rawValue); + } + return counts; +} + +function sumRows(rows: ReportRow[], key: CountKey): number { + return rows.reduce((total, row) => total + (row[key] ?? 0), 0); +} + +function decodeFailuresForRow(row: ReportRow): number { + return row.decode_fail ?? 0; +} + +function formatBucket(value: string, bucket: "hour" | "day"): string { + const date = new Date(value); + const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); + const day = `${date.getUTCDate()}`.padStart(2, "0"); + if (bucket === "day") { + return `${date.getUTCFullYear()}-${month}-${day}`; + } + const hour = `${date.getUTCHours()}`.padStart(2, "0"); + return `${date.getUTCFullYear()}-${month}-${day} ${hour}:00`; +} + +function asTrimmedString(value: unknown, fieldName: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${fieldName} must be a non-empty string.`); + } + return value.trim(); +} + +function asIsoString(value: unknown, fieldName: string): string { + const stringValue = asTrimmedString(value, fieldName); + if (Number.isNaN(Date.parse(stringValue))) { + throw new Error(`${fieldName} must be a valid ISO-8601 timestamp.`); + } + return new Date(stringValue).toISOString(); +} + +function asNullableString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function asNullableNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..451b717 --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,20 @@ +name = "meshcore-sar-stats" +main = "worker/index.ts" +compatibility_date = "2026-04-03" +compatibility_flags = ["nodejs_compat"] + +[observability.logs] +enabled = false + +[placement] +mode = "smart" + +[assets] +directory = "./dist" +not_found_handling = "404-page" +run_worker_first = ["/api/*"] + +[[d1_databases]] +binding = "DB" +database_name = "meshcore_sar_rx_stats" +database_id = "94a3978f-13d9-4dc8-9a3c-2beb102699e5"